From 00373540f913744c0aaa9762002918f209b633cc Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Fri, 26 Jun 2026 11:03:00 -0400 Subject: [PATCH 01/18] server-side pagination and search for contract codes --- frontends/api/package.json | 3 +- .../mitxonline/hooks/organizations/index.ts | 34 +-- .../mitxonline/hooks/organizations/queries.ts | 35 +++- .../test-utils/factories/contracts.ts | 24 ++- .../api/src/mitxonline/test-utils/urls.ts | 15 +- frontends/main/package.json | 2 +- .../ContractAdminPage/AssignSeatsSection.tsx | 23 +-- .../ContractAdminPage.test.tsx | 194 +++++++++--------- .../ContractAdminPage/ContractAdminPage.tsx | 192 ++++++++++------- .../ContractAdminPage/RowActionMenu.test.tsx | 43 +--- .../ContractAdminPage/RowActionMenu.tsx | 104 +++++----- yarn.lock | 12 +- 12 files changed, 367 insertions(+), 314 deletions(-) diff --git a/frontends/api/package.json b/frontends/api/package.json index ae1b490837..62f1ba8e04 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -16,6 +16,7 @@ "./test-utils/mockAxios": "./src/test-utils/mockAxios.ts", "./test-utils": "./src/test-utils/index.ts", "./mitxonline-hooks/*": "./src/mitxonline/hooks/*/index.ts", + "./mitxonline-clients": "./src/mitxonline/clients.ts", "./mitxonline-test-utils": "./src/mitxonline/test-utils/index.ts" }, "peerDependencies": { @@ -31,7 +32,7 @@ "ol-test-utilities": "0.0.0" }, "dependencies": { - "@mitodl/mitxonline-api-axios": "2026.6.17", + "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7af6a2c/src/typescript/mitxonline-api-axios/package.tgz", "@tanstack/react-query": "^5.66.0", "axios": "^1.12.2", "tiny-invariant": "^1.3.3" diff --git a/frontends/api/src/mitxonline/hooks/organizations/index.ts b/frontends/api/src/mitxonline/hooks/organizations/index.ts index 6be06ed91f..19f9ea0743 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/index.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/index.ts @@ -39,10 +39,10 @@ const useBulkAssignSeats = () => { ) => b2bApi.b2bManagerOrganizationsContractsCodesBulkAssignCreate(opts), onSettled: (_data, _err, vars) => { queryClient.invalidateQueries({ - queryKey: managerOrganizationKeys.contractCodes({ - id: vars.id, - parent_lookup_organization: vars.parent_lookup_organization, - }), + queryKey: managerOrganizationKeys.contractCodesForContract( + vars.id, + vars.parent_lookup_organization, + ), }) }, }) @@ -57,10 +57,10 @@ const useRemindCode = () => { ) => b2bApi.b2bManagerOrganizationsContractsCodesRemindCreate(opts), onSettled: (_data, _err, vars) => { queryClient.invalidateQueries({ - queryKey: managerOrganizationKeys.contractCodes({ - id: vars.id, - parent_lookup_organization: vars.parent_lookup_organization, - }), + queryKey: managerOrganizationKeys.contractCodesForContract( + vars.id, + vars.parent_lookup_organization, + ), }) }, }) @@ -75,10 +75,10 @@ const useRevokeCode = () => { ) => b2bApi.b2bManagerOrganizationsContractsCodesRevokeDestroy(opts), onSettled: (_data, _err, vars) => { queryClient.invalidateQueries({ - queryKey: managerOrganizationKeys.contractCodes({ - id: vars.id, - parent_lookup_organization: vars.parent_lookup_organization, - }), + queryKey: managerOrganizationKeys.contractCodesForContract( + vars.id, + vars.parent_lookup_organization, + ), }) }, }) @@ -97,10 +97,10 @@ const useReassignCode = () => { ) => b2bApi.b2bManagerOrganizationsContractsCodesReassignUpdate(opts), onSettled: (_data, _err, vars) => { queryClient.invalidateQueries({ - queryKey: managerOrganizationKeys.contractCodes({ - id: vars.id, - parent_lookup_organization: vars.parent_lookup_organization, - }), + queryKey: managerOrganizationKeys.contractCodesForContract( + vars.id, + vars.parent_lookup_organization, + ), }) }, }) @@ -115,4 +115,4 @@ export { useRemindCode, useRevokeCode, } -export type { ContractCode } from "./queries" +export type { ContractCode, PaginatedContractCodes } from "./queries" diff --git a/frontends/api/src/mitxonline/hooks/organizations/queries.ts b/frontends/api/src/mitxonline/hooks/organizations/queries.ts index 418cd7ab9d..f9c9345153 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/queries.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/queries.ts @@ -4,6 +4,8 @@ import { OrganizationPage, ManagerContractDetail, ManagerEnrollmentCode, + PaginatedManagerEnrollmentCodeList, + PaginatedOrganizationPageList, B2bApiB2bOrganizationsRetrieveRequest, B2bApiB2bManagerOrganizationsContractsRetrieveRequest, B2bApiB2bManagerOrganizationsContractsCodesListRequest, @@ -13,6 +15,13 @@ type ContractCode = ManagerEnrollmentCode & { redemption_status: "unassigned" | "assigned" | "redeemed" } +type PaginatedContractCodes = Omit< + PaginatedManagerEnrollmentCodeList, + "results" +> & { + results: ContractCode[] +} + const organizationKeys = { root: ["mitxonline", "organizations"], organizationsRetrieve: (opts?: B2bApiB2bOrganizationsRetrieveRequest) => [ @@ -47,9 +56,20 @@ const managerOrganizationKeys = { ] as const, contractCodesRoot: () => ["mitxonline", "manager", "organizations", "contracts", "codes"] as const, + // Prefix key used for invalidation — matches all pages/search states for one contract + contractCodesForContract: (id: number, orgId: number) => + [...managerOrganizationKeys.contractCodesRoot(), id, orgId] as const, contractCodes: ( opts: B2bApiB2bManagerOrganizationsContractsCodesListRequest, - ) => [...managerOrganizationKeys.contractCodesRoot(), opts] as const, + ) => + [ + ...managerOrganizationKeys.contractCodesForContract( + opts.id, + opts.parent_lookup_organization, + ), + opts.page, + opts.search_term, + ] as const, } const managerOrganizationQueries = { @@ -57,7 +77,12 @@ const managerOrganizationQueries = { queryOptions({ queryKey: managerOrganizationKeys.list(), queryFn: async (): Promise => - b2bApi.b2bManagerOrganizationsList().then((res) => res.data), + b2bApi.b2bManagerOrganizationsList().then((res) => { + const data = res.data as + | PaginatedOrganizationPageList + | OrganizationPage[] + return Array.isArray(data) ? data : data.results + }), }), managerContractDetail: ( opts: B2bApiB2bManagerOrganizationsContractsRetrieveRequest, @@ -74,10 +99,10 @@ const managerOrganizationQueries = { ) => queryOptions({ queryKey: managerOrganizationKeys.contractCodes(opts), - queryFn: async (): Promise => + queryFn: async (): Promise => b2bApi .b2bManagerOrganizationsContractsCodesList(opts) - .then((res) => res.data as ContractCode[]), + .then((res) => res.data as PaginatedContractCodes), }), } @@ -88,4 +113,4 @@ export { managerOrganizationKeys, } -export type { ContractCode } +export type { ContractCode, PaginatedContractCodes } diff --git a/frontends/api/src/mitxonline/test-utils/factories/contracts.ts b/frontends/api/src/mitxonline/test-utils/factories/contracts.ts index a0bbc3c6d5..6b6cd98ad6 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/contracts.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/contracts.ts @@ -5,7 +5,10 @@ import type { ContractPage, } from "@mitodl/mitxonline-api-axios/v2" import { makePaginatedFactory } from "ol-test-utilities" -import type { ContractCode } from "../../hooks/organizations" +import type { + ContractCode, + PaginatedContractCodes, +} from "../../hooks/organizations" const contract = (overrides: Partial = {}): ContractPage => ({ id: faker.number.int(), @@ -26,6 +29,16 @@ const contract = (overrides: Partial = {}): ContractPage => ({ const contracts = makePaginatedFactory(contract) +const paginatedContractCodes = ( + results: ContractCode[], + opts: { count?: number; next?: string | null; previous?: string | null } = {}, +): PaginatedContractCodes => ({ + count: opts.count ?? results.length, + next: opts.next ?? null, + previous: opts.previous ?? null, + results, +}) + const contractCode = (overrides: Partial = {}): ContractCode => { const status = overrides.redemption_status ?? "assigned" const isAssigned = status !== "unassigned" @@ -61,4 +74,11 @@ const bulkAssignResult = ( ...overrides, }) -export { contract, contracts, contractCode, bulkAssignError, bulkAssignResult } +export { + contract, + contracts, + contractCode, + paginatedContractCodes, + bulkAssignError, + bulkAssignResult, +} diff --git a/frontends/api/src/mitxonline/test-utils/urls.ts b/frontends/api/src/mitxonline/test-utils/urls.ts index f8937a1a12..1d006bd1a6 100644 --- a/frontends/api/src/mitxonline/test-utils/urls.ts +++ b/frontends/api/src/mitxonline/test-utils/urls.ts @@ -90,8 +90,19 @@ const contracts = { contractsList: () => `${getApiBaseUrl()}/api/v0/b2b/contracts/`, managerContractDetail: (orgId: number, contractId: number) => `${getApiBaseUrl()}/api/v0/b2b/manager/organizations/${orgId}/contracts/${contractId}/`, - managerContractCodes: (orgId: number, contractId: number) => - `${getApiBaseUrl()}/api/v0/b2b/manager/organizations/${orgId}/contracts/${contractId}/codes/`, + managerContractCodes: ( + orgId: number, + contractId: number, + params?: { page?: number; page_size?: number; search_term?: string }, + ) => { + // queryify does not filter undefined values, so strip them first to avoid e.g. search_term=undefined in the URL + const clean = params + ? Object.fromEntries( + Object.entries(params).filter(([, v]) => v !== undefined), + ) + : undefined + return `${getApiBaseUrl()}/api/v0/b2b/manager/organizations/${orgId}/contracts/${contractId}/codes/${queryify(clean)}` + }, managerContractBulkAssign: (orgId: number, contractId: number) => `${getApiBaseUrl()}/api/v0/b2b/manager/organizations/${orgId}/contracts/${contractId}/codes/bulk_assign/`, managerContractCodeRemind: ( diff --git a/frontends/main/package.json b/frontends/main/package.json index d90df0aa7d..afb0fbb175 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -16,7 +16,7 @@ "@floating-ui/react": "^0.27.16", "@mitodl/arithmix": "^0.2.2", "@mitodl/course-search-utils": "^3.5.2", - "@mitodl/mitxonline-api-axios": "2026.6.17", + "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7af6a2c/src/typescript/mitxonline-api-axios/package.tgz", "@mitodl/smoot-design": "^6.27.0", "@mui/base": "5.0.0-beta.70", "@mui/material": "^6.4.5", diff --git a/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.tsx b/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.tsx index a98b4dde90..648865395c 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.tsx @@ -1,7 +1,7 @@ "use client" import React, { useEffect, useMemo, useRef, useState } from "react" -import { Stack, Tooltip, Typography, styled } from "ol-components" +import { Link, Stack, Typography, styled } from "ol-components" import { Alert, Button, VisuallyHidden } from "@mitodl/smoot-design" import { isValidEmail, @@ -59,14 +59,6 @@ const ActiveLink = styled.span(({ theme }) => ({ "&:hover": { opacity: 0.8 }, })) -const DisabledLink = styled.span(({ theme }) => ({ - ...theme.typography.subtitle2, - color: theme.custom.colors.darkRed, - textDecoration: "underline", - cursor: "not-allowed", - opacity: 0.5, -})) - const CountBadge = styled.div<{ $variant: "valid" | "warning" | "default" }>( ({ theme, $variant }) => ({ display: "inline-flex", @@ -215,12 +207,14 @@ type AssignSeatsSectionProps = { orgId: number contractId: number availableSeats: number + isLoadingSeats: boolean } const AssignSeatsSection: React.FC = ({ orgId, contractId, availableSeats, + isLoadingSeats, }) => { const [emailInput, setEmailInput] = useState("") const [focused, setFocused] = useState(false) @@ -514,7 +508,7 @@ const AssignSeatsSection: React.FC = ({ - - + @@ -645,9 +695,9 @@ const ContractAdminPageInternal: React.FC = ({ {isLoadingCodes ? "Loading seat assignments" - : filteredCodes.length === 0 + : tabFilteredCodes.length === 0 ? "No seat assignments found" - : `Showing ${filteredCodes.length === 0 ? 0 : (page - 1) * PAGE_SIZE + 1}–${Math.min(page * PAGE_SIZE, filteredCodes.length)} of ${filteredCodes.length} assignment${filteredCodes.length !== 1 ? "s" : ""}`} + : `Showing page ${page} of ${totalPages}`} {isLoadingCodes ? ( <> @@ -659,7 +709,7 @@ const ContractAdminPageInternal: React.FC = ({ ))} - ) : pagedCodes.length === 0 ? ( + ) : tabFilteredCodes.length === 0 ? ( = ({ ) : ( - pagedCodes.map((code) => ( + tabFilteredCodes.map((code) => ( = ({ {totalPages > 1 && ( { }) describe("redeemed code", () => { - test("opens menu with only Uninvite item", async () => { + test("renders a disabled button with no menu for redeemed codes", async () => { const code = makeCode({ redemption_status: "redeemed", redeemed_by: "user@example.com", @@ -64,12 +64,14 @@ describe("RowActionMenu", () => { }) renderMenu(code) - await user.click(screen.getByRole("button", { name: /more actions/i })) + const button = screen.getByRole("button", { + name: /no actions available/i, + }) + expect(button).toBeDisabled() - expect( - screen.getByRole("menuitem", { name: "Uninvite" }), - ).toBeInTheDocument() - expect(screen.queryByText("Copy claim link")).not.toBeInTheDocument() + // Clicking the disabled button should not open any menu + await user.click(button) + expect(screen.queryByRole("menu")).not.toBeInTheDocument() }) }) @@ -174,35 +176,6 @@ describe("RowActionMenu", () => { expect(onResult).toHaveBeenCalledWith("Seat released.", "success") }) }) - - test("surfaces the already-redeemed (409) error", async () => { - const code = makeCode({ - redemption_status: "redeemed", - redeemed_by: "user@example.com", - redeemed_on: new Date().toISOString(), - }) - setMockResponse.delete( - urls.contracts.managerContractCodeRevoke( - ORG_ID, - CONTRACT_ID, - code.code, - ), - { detail: "Cannot revoke a code that has already been redeemed." }, - { code: 409 }, - ) - const { onResult } = renderMenu(code) - - await user.click(screen.getByRole("button", { name: /more actions/i })) - await user.click(screen.getByRole("menuitem", { name: "Uninvite" })) - await user.click(screen.getByRole("button", { name: "Uninvite" })) - - await waitFor(() => { - expect(onResult).toHaveBeenCalledWith( - expect.stringContaining("already been redeemed"), - "error", - ) - }) - }) }) describe("Change assigned email / reassign", () => { diff --git a/frontends/main/src/app-pages/ContractAdminPage/RowActionMenu.tsx b/frontends/main/src/app-pages/ContractAdminPage/RowActionMenu.tsx index 15fd264267..b6a2acb145 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/RowActionMenu.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/RowActionMenu.tsx @@ -75,10 +75,10 @@ type RowActionMenuProps = { /** * Three-dot row action menu for the contract admin codes table. * - * Pending rows: Change assigned email (reassign, confirmed), Resend claim - * email (remind), Copy claim link, Release seat (revoke, confirmed). + * Pending (assigned) rows: Change assigned email, Resend claim email, Copy + * claim link, Release seat. * - * Redeemed rows: Uninvite (revoke, confirmed). + * Redeemed rows: button is disabled — no actions available. */ const RowActionMenu: React.FC = ({ code, @@ -137,15 +137,9 @@ const RowActionMenu: React.FC = ({ id: contractId, parent_lookup_organization: orgId, }) - onResult(isRedeemed ? "Learner uninvited." : "Seat released.", "success") - } catch (err) { - const status = (err as AxiosError)?.response?.status - onResult( - status === 409 - ? "This code has already been redeemed and cannot be revoked." - : "Could not complete the action. Please try again.", - "error", - ) + onResult("Seat released.", "success") + } catch { + onResult("Could not release the seat. Please try again.", "error") } // Dialog closes itself once this resolves. } @@ -223,45 +217,19 @@ const RowActionMenu: React.FC = ({ } } - const menuItems = isRedeemed ? ( - - Uninvite - - ) : ( - [ - - Change assigned email - , - hasAssignedEmail ? ( - - Resend claim email - - ) : ( - - - Resend claim email - - - ), - copied ? ( - - Link copied to clipboard - - ) : ( - - Copy claim link - - ), - , - - Release seat - , - ] - ) + // Redeemed rows have no available actions — render a disabled button only. + if (isRedeemed) { + return ( + + + + ) + } return ( <> @@ -287,21 +255,43 @@ const RowActionMenu: React.FC = ({ onClose={handleClose} MenuListProps={{ "aria-labelledby": `row-action-trigger-${code.id}` }} > - {menuItems} + + Change assigned email + + {hasAssignedEmail ? ( + + Resend claim email + + ) : ( + + + Resend claim email + + + )} + {copied ? ( + Link copied to clipboard + ) : ( + + Copy claim link + + )} + + + Release seat + setRevokeConfirmOpen(false)} onConfirm={handleRevokeConfirm} - title={isRedeemed ? "Uninvite learner" : "Release seat"} - confirmText={isRedeemed ? "Uninvite" : "Release seat"} + title="Release seat" + confirmText="Release seat" maxWidth="sm" aria-describedby={revokeDescId} >

- {isRedeemed - ? `This will remove ${assignedTo}'s access to the program. This cannot be undone.` - : `This will revoke the invitation for ${assignedTo} and return the seat to the unassigned pool.`} + {`This will revoke the invitation for ${assignedTo} and return the seat to the unassigned pool.`}

Date: Fri, 26 Jun 2026 11:15:37 -0400 Subject: [PATCH 02/18] add isLoadingSeats to AssignSeatsSection test renders and drop page_size from contract codes query --- .../AssignSeatsSection.test.tsx | 178 +++++++++++++++--- .../ContractAdminPage.test.tsx | 5 - .../ContractAdminPage/ContractAdminPage.tsx | 10 +- 3 files changed, 159 insertions(+), 34 deletions(-) diff --git a/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.test.tsx b/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.test.tsx index 793aaba7ff..20432b2188 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.test.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.test.tsx @@ -51,7 +51,12 @@ describe("AssignSeatsSection", () => { test("renders section title and key UI elements", () => { renderWithProviders( - , + , ) expect( @@ -70,7 +75,12 @@ describe("AssignSeatsSection", () => { test("download sample CSV pseudo-link is in the tab order and disabled", () => { renderWithProviders( - , + , ) // Only the download link is still disabled — import from CSV is now active @@ -83,7 +93,12 @@ describe("AssignSeatsSection", () => { test("import from CSV button is active and in the tab order", () => { renderWithProviders( - , + , ) const importButton = screen.getByRole("button", { name: "import from CSV" }) @@ -94,7 +109,12 @@ describe("AssignSeatsSection", () => { test("Assign Seats button is disabled when textarea is empty", () => { renderWithProviders( - , + , ) expect(screen.getByRole("button", { name: "Assign Seats" })).toBeDisabled() @@ -102,7 +122,12 @@ describe("AssignSeatsSection", () => { test("Assign Seats button is disabled when valid email count exceeds available seats", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -113,7 +138,12 @@ describe("AssignSeatsSection", () => { test("Assign Seats button enables when at least one valid email is entered", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -126,7 +156,12 @@ describe("AssignSeatsSection", () => { test("Assign Seats button stays disabled when only invalid emails are entered", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -137,7 +172,12 @@ describe("AssignSeatsSection", () => { test("sole invalid token is not highlighted while focused (no preceding comma)", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -150,7 +190,12 @@ describe("AssignSeatsSection", () => { test("last invalid token is highlighted while focused when preceded by a comma", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -163,7 +208,12 @@ describe("AssignSeatsSection", () => { test("last invalid token is highlighted after blur (no trailing comma)", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -177,7 +227,12 @@ describe("AssignSeatsSection", () => { test("shows valid/invalid counts after entering emails", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -189,7 +244,12 @@ describe("AssignSeatsSection", () => { test("shows only valid count when all emails are valid", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -201,7 +261,12 @@ describe("AssignSeatsSection", () => { test("validation badge is not shown when textarea is empty", () => { renderWithProviders( - , + , ) expect(screen.queryByText(/valid/i)).not.toBeInTheDocument() @@ -209,7 +274,12 @@ describe("AssignSeatsSection", () => { test("live region announces email validation summary after debounce", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -229,7 +299,12 @@ describe("AssignSeatsSection", () => { test("clicking Assign Seats opens the confirm modal", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -245,7 +320,12 @@ describe("AssignSeatsSection", () => { test("modal shows invalid emails when textarea has mixed input", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -258,7 +338,12 @@ describe("AssignSeatsSection", () => { test("modal shows duplicate count when textarea has repeated emails", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -273,7 +358,12 @@ describe("AssignSeatsSection", () => { test("closing the modal hides it", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -294,7 +384,12 @@ describe("AssignSeatsSection", () => { test("importing a valid CSV opens the modal without populating the textarea", async () => { renderWithProviders( - , + , ) const csvContent = "email\nalice@example.com\nbob@example.com" @@ -318,7 +413,12 @@ describe("AssignSeatsSection", () => { test("importing a CSV with invalid emails shows them in the modal", async () => { renderWithProviders( - , + , ) const csvContent = "alice@example.com\nbad@\nnot-quite@.com" @@ -336,7 +436,12 @@ describe("AssignSeatsSection", () => { test("importing a CSV with duplicates shows duplicate count in modal", async () => { renderWithProviders( - , + , ) const csvContent = "alice@example.com\nbob@example.com\nalice@example.com" @@ -355,7 +460,12 @@ describe("AssignSeatsSection", () => { test("importing a CSV with no-@ rows shows skipped count in modal", async () => { renderWithProviders( - , + , ) const csvContent = @@ -375,7 +485,12 @@ describe("AssignSeatsSection", () => { test("importing a CSV with no valid emails shows inline error", async () => { renderWithProviders( - , + , ) const csvContent = "Email\nNot An Email\nbad@" @@ -397,7 +512,12 @@ describe("AssignSeatsSection", () => { test("assertive live region announces CSV error text for screen readers", async () => { renderWithProviders( - , + , ) const csvContent = "Email\nNot An Email\nbad@" @@ -420,7 +540,12 @@ describe("AssignSeatsSection", () => { test("accepts newline-separated emails", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -462,6 +587,7 @@ describe("AssignSeatsSection", () => { orgId={ORG_ID} contractId={CONTRACT_ID} availableSeats={50} + isLoadingSeats={false} />, ) @@ -492,6 +618,7 @@ describe("AssignSeatsSection", () => { orgId={ORG_ID} contractId={CONTRACT_ID} availableSeats={50} + isLoadingSeats={false} />, ) @@ -514,6 +641,7 @@ describe("AssignSeatsSection", () => { orgId={ORG_ID} contractId={CONTRACT_ID} availableSeats={50} + isLoadingSeats={false} />, ) diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx index de3455da63..4500f77b19 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx @@ -154,7 +154,6 @@ describe("ContractAdminPage", () => { setMockResponse.get( urls.contracts.managerContractCodes(org.id, contract.id, { page: 1, - page_size: 20, }), factories.contracts.paginatedContractCodes([]), ) @@ -180,7 +179,6 @@ describe("ContractAdminPage", () => { setMockResponse.get( urls.contracts.managerContractCodes(org.id, contract.id, { page: 1, - page_size: 20, }), factories.contracts.paginatedContractCodes([]), ) @@ -210,7 +208,6 @@ describe("ContractAdminPage", () => { setMockResponse.get( urls.contracts.managerContractCodes(org.id, contract.id, { page: 1, - page_size: 20, }), factories.contracts.paginatedContractCodes([]), ) @@ -265,7 +262,6 @@ describe("ContractAdminPage", () => { setMockResponse.get( urls.contracts.managerContractCodes(org.id, contract.id, { page: 1, - page_size: 20, }), factories.contracts.paginatedContractCodes([assignedCode, redeemedCode]), ) @@ -312,7 +308,6 @@ describe("ContractAdminPage", () => { setMockResponse.get( urls.contracts.managerContractCodes(org.id, contract.id, { page: 1, - page_size: 20, }), factories.contracts.paginatedContractCodes([assignedCode, redeemedCode]), ) diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx index 1275923505..141e720b6c 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx @@ -35,8 +35,6 @@ import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" import { ErrorContent } from "../ErrorPage/ErrorPageTemplate" import graduateLogo from "@/public/images/dashboard/graduate.png" -const PAGE_SIZE = 20 - const Page = styled(Container)(({ theme }) => ({ maxWidth: "1400px", padding: "40px 24px", @@ -411,7 +409,6 @@ const ContractAdminPageInternal: React.FC = ({ id: contract?.id ?? 0, parent_lookup_organization: org?.id ?? 0, page, - page_size: PAGE_SIZE, search_term: debouncedSearchQuery || undefined, }), enabled: !!org && !!contract, @@ -483,7 +480,12 @@ const ContractAdminPageInternal: React.FC = ({ }) const totalCount = codes?.count ?? 0 - const totalPages = Math.ceil(totalCount / PAGE_SIZE) + // Derive total pages from the response: if there's a next page we're on a + // full page so results.length equals the backend page size; otherwise this + // is the last page so totalPages equals the current page number. + const totalPages = codes?.next + ? Math.ceil(totalCount / pageResults.length) + : page const handleTabChange = (_: React.SyntheticEvent, val: StatusFilter) => { setStatusFilter(val) From fe345176803e7bb19582034374f980818494f853 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Fri, 26 Jun 2026 11:52:02 -0400 Subject: [PATCH 03/18] export CSV guards, totalPages div-by-zero, and tab filter TODO --- .../AssignSeatsSection.test.tsx | 9 +- .../ContractAdminPage/ContractAdminPage.tsx | 92 +++++++++++-------- 2 files changed, 58 insertions(+), 43 deletions(-) diff --git a/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.test.tsx b/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.test.tsx index 20432b2188..5aa9de1f41 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.test.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.test.tsx @@ -73,7 +73,7 @@ describe("AssignSeatsSection", () => { expect(screen.getByText("(download sample CSV)")).toBeInTheDocument() }) - test("download sample CSV pseudo-link is in the tab order and disabled", () => { + test("download sample CSV link is rendered", () => { renderWithProviders( { />, ) - // Only the download link is still disabled — import from CSV is now active - const downloadLink = screen.getByRole("button", { + // The download link is a real anchor — in the tab order and not disabled + const downloadLink = screen.getByRole("link", { name: "(download sample CSV)", }) - expect(downloadLink).toHaveAttribute("tabindex", "0") - expect(downloadLink).toHaveAttribute("aria-disabled", "true") + expect(downloadLink).toBeInTheDocument() }) test("import from CSV button is active and in the tab order", () => { diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx index 141e720b6c..d105bc82a8 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx @@ -470,6 +470,9 @@ const ContractAdminPageInternal: React.FC = ({ // Tab filter is applied client-side on the current page of results. // Unassigned codes are excluded server-side (API only returns assigned/redeemed). + // TODO: pass statusFilter as a server-side query param once the API supports a + // redemption_status filter — client-side filtering against paginated results means + // pagination counts reflect all statuses, not the active tab's subset. const pageResults = codes?.results ?? [] const tabFilteredCodes = pageResults.filter((code) => { return ( @@ -483,9 +486,10 @@ const ContractAdminPageInternal: React.FC = ({ // Derive total pages from the response: if there's a next page we're on a // full page so results.length equals the backend page size; otherwise this // is the last page so totalPages equals the current page number. - const totalPages = codes?.next - ? Math.ceil(totalCount / pageResults.length) - : page + const totalPages = + codes?.next && pageResults.length > 0 + ? Math.ceil(totalCount / pageResults.length) + : page const handleTabChange = (_: React.SyntheticEvent, val: StatusFilter) => { setStatusFilter(val) @@ -497,40 +501,48 @@ const ContractAdminPageInternal: React.FC = ({ } const handleExportCsv = async () => { - const response = await b2bApi.b2bManagerOrganizationsContractsCodesList({ - id: contract.id, - parent_lookup_organization: org.id, - page_size: totalPurchased, - }) - const rows = response.data.results - const header = buildCsvRow([ - "Assigned to", - "Redeemed by", - "Status", - "Assigned on", - "Redeemed on", - "Last sent", - ]) - const dataRows = rows.map((c) => - buildCsvRow([ - c.assigned_to, - c.redeemed_by, - c.redemption_status === "redeemed" ? "Redeemed" : "Pending claim", - formatDate(c.assigned_on), - formatDate(c.redeemed_on), - formatDate(c.last_sent), - ]), - ) - const csv = [header, ...dataRows].join("\n") - const blob = new Blob([csv], { type: "text/csv" }) - const url = URL.createObjectURL(blob) - const link = document.createElement("a") - link.href = url - link.download = "seat-assignments.csv" - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - URL.revokeObjectURL(url) + if (!totalPurchased) return + try { + const response = await b2bApi.b2bManagerOrganizationsContractsCodesList({ + id: contract.id, + parent_lookup_organization: org.id, + page_size: totalPurchased, + }) + const rows = response.data.results + const header = buildCsvRow([ + "Assigned to", + "Redeemed by", + "Status", + "Assigned on", + "Redeemed on", + "Last sent", + ]) + const dataRows = rows.map((c) => + buildCsvRow([ + c.assigned_to, + c.redeemed_by, + c.redemption_status === "redeemed" ? "Redeemed" : "Pending claim", + formatDate(c.assigned_on), + formatDate(c.redeemed_on), + formatDate(c.last_sent), + ]), + ) + const csv = [header, ...dataRows].join("\n") + const blob = new Blob([csv], { type: "text/csv" }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = "seat-assignments.csv" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } catch { + setRowActionResult({ + message: "Could not export CSV. Please try again.", + severity: "error", + }) + } } return ( @@ -642,7 +654,11 @@ const ContractAdminPageInternal: React.FC = ({ /> - From 181110dd7a034f06e6490478d6e45f186a54410f Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Fri, 26 Jun 2026 13:55:39 -0400 Subject: [PATCH 04/18] move contract codes tab filtering server-side and fix CSV pagination --- frontends/api/package.json | 2 +- .../mitxonline/hooks/organizations/queries.ts | 1 + .../api/src/mitxonline/test-utils/urls.ts | 7 +- frontends/main/package.json | 2 +- .../ContractAdminPage.test.tsx | 168 +++++++++++++++++- .../ContractAdminPage/ContractAdminPage.tsx | 58 +++--- .../ContractAdminPage/RowActionMenu.tsx | 10 +- yarn.lock | 10 +- 8 files changed, 226 insertions(+), 32 deletions(-) diff --git a/frontends/api/package.json b/frontends/api/package.json index 62f1ba8e04..7a25d1a0fe 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -32,7 +32,7 @@ "ol-test-utilities": "0.0.0" }, "dependencies": { - "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7af6a2c/src/typescript/mitxonline-api-axios/package.tgz", + "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7dbc25d/src/typescript/mitxonline-api-axios/package.tgz", "@tanstack/react-query": "^5.66.0", "axios": "^1.12.2", "tiny-invariant": "^1.3.3" diff --git a/frontends/api/src/mitxonline/hooks/organizations/queries.ts b/frontends/api/src/mitxonline/hooks/organizations/queries.ts index f9c9345153..556edf0125 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/queries.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/queries.ts @@ -69,6 +69,7 @@ const managerOrganizationKeys = { ), opts.page, opts.search_term, + opts.status, ] as const, } diff --git a/frontends/api/src/mitxonline/test-utils/urls.ts b/frontends/api/src/mitxonline/test-utils/urls.ts index 1d006bd1a6..9fb471e41f 100644 --- a/frontends/api/src/mitxonline/test-utils/urls.ts +++ b/frontends/api/src/mitxonline/test-utils/urls.ts @@ -93,7 +93,12 @@ const contracts = { managerContractCodes: ( orgId: number, contractId: number, - params?: { page?: number; page_size?: number; search_term?: string }, + params?: { + page?: number + page_size?: number + search_term?: string + status?: string + }, ) => { // queryify does not filter undefined values, so strip them first to avoid e.g. search_term=undefined in the URL const clean = params diff --git a/frontends/main/package.json b/frontends/main/package.json index afb0fbb175..6cfe269a24 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -16,7 +16,7 @@ "@floating-ui/react": "^0.27.16", "@mitodl/arithmix": "^0.2.2", "@mitodl/course-search-utils": "^3.5.2", - "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7af6a2c/src/typescript/mitxonline-api-axios/package.tgz", + "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7dbc25d/src/typescript/mitxonline-api-axios/package.tgz", "@mitodl/smoot-design": "^6.27.0", "@mui/base": "5.0.0-beta.70", "@mui/material": "^6.4.5", diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx index 4500f77b19..13621b8362 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx @@ -1,5 +1,6 @@ import React from "react" import { renderWithProviders, screen, user } from "@/test-utils" +import { waitFor } from "@testing-library/react" import { setMockResponse } from "api/test-utils" import { factories, urls } from "api/mitxonline-test-utils" import { useFeatureFlagEnabled } from "posthog-js/react" @@ -265,6 +266,13 @@ describe("ContractAdminPage", () => { }), factories.contracts.paginatedContractCodes([assignedCode, redeemedCode]), ) + setMockResponse.get( + urls.contracts.managerContractCodes(org.id, contract.id, { + page: 1, + status: "redeemed", + }), + factories.contracts.paginatedContractCodes([redeemedCode]), + ) renderWithProviders( , @@ -274,10 +282,157 @@ describe("ContractAdminPage", () => { await user.click(screen.getByRole("tab", { name: "Redeemed" })) - expect(screen.getByText("redeemed@example.com")).toBeInTheDocument() + await screen.findByText("redeemed@example.com") expect(screen.queryByText("pending@example.com")).not.toBeInTheDocument() }) + describe("CSV export", () => { + const setupPage = ( + org: ReturnType, + contract: ReturnType, + overrides: Parameters[1] = {}, + ) => { + setMockResponse.get(managerOrgsUrl, [org]) + setMockResponse.get( + managerContractDetailUrl(org.id, contract.id), + makeContractDetail(contract, overrides), + ) + setMockResponse.get( + urls.contracts.managerContractCodes(org.id, contract.id, { page: 1 }), + factories.contracts.paginatedContractCodes([]), + ) + } + + let clickedAnchor: HTMLAnchorElement | null = null + const mockCreateObjectURL = jest.fn().mockReturnValue("blob:fake-url") + const mockRevokeObjectURL = jest.fn() + + beforeEach(() => { + mockedUseFeatureFlagsLoaded.mockReturnValue(true) + mockedUseFeatureFlagEnabled.mockReturnValue(true) + clickedAnchor = null + mockCreateObjectURL.mockClear() + mockRevokeObjectURL.mockClear() + URL.createObjectURL = mockCreateObjectURL + URL.revokeObjectURL = mockRevokeObjectURL + jest + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(function (this: HTMLAnchorElement) { + clickedAnchor = this + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + test("triggers download with correct filename for a single page of results", async () => { + const { org, contract } = makeOrgWithContract() + setupPage(org, contract, { total_codes: 2 }) + + setMockResponse.get( + urls.contracts.managerContractCodes(org.id, contract.id, { + page: 1, + page_size: 500, + }), + factories.contracts.paginatedContractCodes([ + factories.contracts.contractCode({ + assigned_to: "alice@example.com", + }), + factories.contracts.contractCode({ assigned_to: "bob@example.com" }), + ]), + ) + + renderWithProviders( + , + ) + + await screen.findByRole("button", { name: "Export CSV" }) + await user.click(screen.getByRole("button", { name: "Export CSV" })) + + await waitFor(() => { + expect(mockCreateObjectURL).toHaveBeenCalledWith(expect.any(Blob)) + }) + expect(clickedAnchor).not.toBeNull() + expect(clickedAnchor!.download).toBe("seat-assignments.csv") + expect(mockRevokeObjectURL).toHaveBeenCalledWith("blob:fake-url") + await screen.findByText("CSV download started.") + }) + + test("fetches additional pages and includes all rows in the CSV", async () => { + const { org, contract } = makeOrgWithContract() + setupPage(org, contract, { total_codes: 2 }) + + setMockResponse.get( + urls.contracts.managerContractCodes(org.id, contract.id, { + page: 1, + page_size: 500, + }), + factories.contracts.paginatedContractCodes( + [ + factories.contracts.contractCode({ + assigned_to: "alice@example.com", + }), + ], + { count: 2, next: "next-page" }, + ), + ) + setMockResponse.get( + urls.contracts.managerContractCodes(org.id, contract.id, { + page: 2, + page_size: 500, + }), + factories.contracts.paginatedContractCodes([ + factories.contracts.contractCode({ assigned_to: "bob@example.com" }), + ]), + ) + + renderWithProviders( + , + ) + + await screen.findByRole("button", { name: "Export CSV" }) + await user.click(screen.getByRole("button", { name: "Export CSV" })) + + await waitFor(() => { + expect(mockCreateObjectURL).toHaveBeenCalledWith(expect.any(Blob)) + }) + const blob = mockCreateObjectURL.mock.calls[0][0] as Blob + const csv = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = reject + reader.readAsText(blob) + }) + expect(csv).toContain("alice@example.com") + expect(csv).toContain("bob@example.com") + }) + + test("shows error alert when the export request fails", async () => { + allowConsoleErrors() + const { org, contract } = makeOrgWithContract() + setupPage(org, contract, { total_codes: 1 }) + + setMockResponse.get( + urls.contracts.managerContractCodes(org.id, contract.id, { + page: 1, + page_size: 500, + }), + "Internal Server Error", + { code: 500 }, + ) + + renderWithProviders( + , + ) + + await screen.findByRole("button", { name: "Export CSV" }) + await user.click(screen.getByRole("button", { name: "Export CSV" })) + + await screen.findByText("Could not export CSV. Please try again.") + }) + }) + test("Pending claim tab filters to assigned codes only", async () => { mockedUseFeatureFlagsLoaded.mockReturnValue(true) mockedUseFeatureFlagEnabled.mockReturnValue(true) @@ -311,6 +466,13 @@ describe("ContractAdminPage", () => { }), factories.contracts.paginatedContractCodes([assignedCode, redeemedCode]), ) + setMockResponse.get( + urls.contracts.managerContractCodes(org.id, contract.id, { + page: 1, + status: "assigned", + }), + factories.contracts.paginatedContractCodes([assignedCode]), + ) renderWithProviders( , @@ -320,7 +482,9 @@ describe("ContractAdminPage", () => { await user.click(screen.getByRole("tab", { name: "Pending claim" })) + await waitFor(() => { + expect(screen.queryByText("redeemed@example.com")).not.toBeInTheDocument() + }) expect(screen.getByText("pending@example.com")).toBeInTheDocument() - expect(screen.queryByText("redeemed@example.com")).not.toBeInTheDocument() }) }) diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx index d105bc82a8..03ea6cbed5 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx @@ -352,6 +352,7 @@ const ContractAdminPageInternal: React.FC = ({ severity: "success" | "error" } | null>(null) const [rowActionAnnouncement, setRowActionAnnouncement] = useState("") + const [isExporting, setIsExporting] = useState(false) // Debounce search query to avoid firing a new request on every keystroke. useEffect(() => { @@ -410,6 +411,12 @@ const ContractAdminPageInternal: React.FC = ({ parent_lookup_organization: org?.id ?? 0, page, search_term: debouncedSearchQuery || undefined, + status: + statusFilter === "redeemed" + ? "redeemed" + : statusFilter === "pending" + ? "assigned" + : undefined, }), enabled: !!org && !!contract, }) @@ -468,19 +475,7 @@ const ContractAdminPageInternal: React.FC = ({ const assignedCount = contractDetail?.assigned_codes const redeemedCount = contractDetail?.redeemed_codes - // Tab filter is applied client-side on the current page of results. - // Unassigned codes are excluded server-side (API only returns assigned/redeemed). - // TODO: pass statusFilter as a server-side query param once the API supports a - // redemption_status filter — client-side filtering against paginated results means - // pagination counts reflect all statuses, not the active tab's subset. const pageResults = codes?.results ?? [] - const tabFilteredCodes = pageResults.filter((code) => { - return ( - statusFilter === "all" || - (statusFilter === "redeemed" && code.redemption_status === "redeemed") || - (statusFilter === "pending" && code.redemption_status === "assigned") - ) - }) const totalCount = codes?.count ?? 0 // Derive total pages from the response: if there's a next page we're on a @@ -502,13 +497,27 @@ const ContractAdminPageInternal: React.FC = ({ const handleExportCsv = async () => { if (!totalPurchased) return + setIsExporting(true) try { - const response = await b2bApi.b2bManagerOrganizationsContractsCodesList({ + const PAGE_SIZE = 500 + let page = 1 + let res = await b2bApi.b2bManagerOrganizationsContractsCodesList({ id: contract.id, parent_lookup_organization: org.id, - page_size: totalPurchased, + page, + page_size: PAGE_SIZE, }) - const rows = response.data.results + const rows = [...res.data.results] + while (res.data.next) { + page += 1 + res = await b2bApi.b2bManagerOrganizationsContractsCodesList({ + id: contract.id, + parent_lookup_organization: org.id, + page, + page_size: PAGE_SIZE, + }) + rows.push(...res.data.results) + } const header = buildCsvRow([ "Assigned to", "Redeemed by", @@ -537,11 +546,17 @@ const ContractAdminPageInternal: React.FC = ({ link.click() document.body.removeChild(link) URL.revokeObjectURL(url) + setRowActionResult({ + message: "CSV download started.", + severity: "success", + }) } catch { setRowActionResult({ message: "Could not export CSV. Please try again.", severity: "error", }) + } finally { + setIsExporting(false) } } @@ -657,9 +672,12 @@ const ContractAdminPageInternal: React.FC = ({ @@ -713,7 +731,7 @@ const ContractAdminPageInternal: React.FC = ({ {isLoadingCodes ? "Loading seat assignments" - : tabFilteredCodes.length === 0 + : pageResults.length === 0 ? "No seat assignments found" : `Showing page ${page} of ${totalPages}`} @@ -727,7 +745,7 @@ const ContractAdminPageInternal: React.FC = ({
))} - ) : tabFilteredCodes.length === 0 ? ( + ) : pageResults.length === 0 ? ( = ({ ) : ( - tabFilteredCodes.map((code) => ( + pageResults.map((code) => ( = ({ parent_lookup_organization: orgId, }) onResult("Seat released.", "success") - } catch { - onResult("Could not release the seat. Please try again.", "error") + } catch (err) { + const status = (err as AxiosError)?.response?.status + onResult( + status === 409 + ? "This seat has already been redeemed and cannot be released." + : "Could not release the seat. Please try again.", + "error", + ) } // Dialog closes itself once this resolves. } diff --git a/yarn.lock b/yarn.lock index 94b8076218..81448a9b77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3344,13 +3344,13 @@ __metadata: languageName: node linkType: hard -"@mitodl/mitxonline-api-axios@https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7af6a2c/src/typescript/mitxonline-api-axios/package.tgz": +"@mitodl/mitxonline-api-axios@https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7dbc25d/src/typescript/mitxonline-api-axios/package.tgz": version: 2026.6.24 - resolution: "@mitodl/mitxonline-api-axios@https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7af6a2c/src/typescript/mitxonline-api-axios/package.tgz" + resolution: "@mitodl/mitxonline-api-axios@https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7dbc25d/src/typescript/mitxonline-api-axios/package.tgz" dependencies: "@types/node": "npm:^20.11.19" axios: "npm:^1.6.5" - checksum: 10/a7eeb3ffb9cf9d87669702c873901d2f56ec61e39d84e3eeaf0942662309cdaf446fb495e04b9e7674dc02478b6dcbabae87d2d7a1c74911847aa9aea2aebc6f + checksum: 10/324d0b95622278848e99b7d60b73d204ef43a8043b9f7105682fc72372a6047980cb43cec7d36d2a0d4c544e4d2351814ffd49f20a4ab43a351d67fc03331e29 languageName: node linkType: hard @@ -8953,7 +8953,7 @@ __metadata: resolution: "api@workspace:frontends/api" dependencies: "@faker-js/faker": "npm:^10.0.0" - "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7af6a2c/src/typescript/mitxonline-api-axios/package.tgz" + "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7dbc25d/src/typescript/mitxonline-api-axios/package.tgz" "@tanstack/react-query": "npm:^5.66.0" "@testing-library/react": "npm:^16.3.0" axios: "npm:^1.12.2" @@ -16180,7 +16180,7 @@ __metadata: "@happy-dom/jest-environment": "npm:^20.1.0" "@mitodl/arithmix": "npm:^0.2.2" "@mitodl/course-search-utils": "npm:^3.5.2" - "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7af6a2c/src/typescript/mitxonline-api-axios/package.tgz" + "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7dbc25d/src/typescript/mitxonline-api-axios/package.tgz" "@mitodl/smoot-design": "npm:^6.27.0" "@mui/base": "npm:5.0.0-beta.70" "@mui/material": "npm:^6.4.5" From d7edf36cd49e32a143b9c96e6ab1f1efedfdfdb2 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Fri, 26 Jun 2026 14:00:52 -0400 Subject: [PATCH 05/18] replace this-alias in anchor click spy with mock.instances --- .../ContractAdminPage/ContractAdminPage.test.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx index 13621b8362..0fabfa3915 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx @@ -303,23 +303,21 @@ describe("ContractAdminPage", () => { ) } - let clickedAnchor: HTMLAnchorElement | null = null + const mockAnchorClick = jest.fn() const mockCreateObjectURL = jest.fn().mockReturnValue("blob:fake-url") const mockRevokeObjectURL = jest.fn() beforeEach(() => { mockedUseFeatureFlagsLoaded.mockReturnValue(true) mockedUseFeatureFlagEnabled.mockReturnValue(true) - clickedAnchor = null + mockAnchorClick.mockClear() mockCreateObjectURL.mockClear() mockRevokeObjectURL.mockClear() URL.createObjectURL = mockCreateObjectURL URL.revokeObjectURL = mockRevokeObjectURL jest .spyOn(HTMLAnchorElement.prototype, "click") - .mockImplementation(function (this: HTMLAnchorElement) { - clickedAnchor = this - }) + .mockImplementation(mockAnchorClick) }) afterEach(() => { @@ -353,8 +351,9 @@ describe("ContractAdminPage", () => { await waitFor(() => { expect(mockCreateObjectURL).toHaveBeenCalledWith(expect.any(Blob)) }) - expect(clickedAnchor).not.toBeNull() - expect(clickedAnchor!.download).toBe("seat-assignments.csv") + expect(mockAnchorClick).toHaveBeenCalledTimes(1) + const clickedAnchor = mockAnchorClick.mock.instances[0] as HTMLAnchorElement + expect(clickedAnchor.download).toBe("seat-assignments.csv") expect(mockRevokeObjectURL).toHaveBeenCalledWith("blob:fake-url") await screen.findByText("CSV download started.") }) From 4c1c03c1a14c208debc0159f3d3b68dc7e3088da Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Fri, 26 Jun 2026 14:02:45 -0400 Subject: [PATCH 06/18] fix whitespace --- .../src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx index 0fabfa3915..e08cb7bcbe 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx @@ -352,7 +352,8 @@ describe("ContractAdminPage", () => { expect(mockCreateObjectURL).toHaveBeenCalledWith(expect.any(Blob)) }) expect(mockAnchorClick).toHaveBeenCalledTimes(1) - const clickedAnchor = mockAnchorClick.mock.instances[0] as HTMLAnchorElement + const clickedAnchor = mockAnchorClick.mock + .instances[0] as HTMLAnchorElement expect(clickedAnchor.download).toBe("seat-assignments.csv") expect(mockRevokeObjectURL).toHaveBeenCalledWith("blob:fake-url") await screen.findByText("CSV download started.") From e40f92d25a28222282f58c9157c13d86f360f762 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Fri, 26 Jun 2026 14:28:19 -0400 Subject: [PATCH 07/18] copilot suggestions --- .../mitxonline/hooks/organizations/queries.ts | 1 + .../ContractAdminPage/ContractAdminPage.tsx | 51 ++++++++++++------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/frontends/api/src/mitxonline/hooks/organizations/queries.ts b/frontends/api/src/mitxonline/hooks/organizations/queries.ts index 556edf0125..3b17081171 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/queries.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/queries.ts @@ -68,6 +68,7 @@ const managerOrganizationKeys = { opts.parent_lookup_organization, ), opts.page, + opts.page_size, opts.search_term, opts.status, ] as const, diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx index 03ea6cbed5..1f72fbda4e 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx @@ -390,15 +390,18 @@ const ContractAdminPageInternal: React.FC = ({ const org = managerOrgs?.find(matchOrganizationBySlug(orgSlug)) const contract = org?.contracts.find((c) => c.slug === contractSlug) - const { data: contractDetail, isLoading: isLoadingContractDetail } = useQuery( - { - ...managerOrganizationQueries.managerContractDetail({ - id: contract?.id ?? 0, - parent_lookup_organization: org?.id ?? 0, - }), - enabled: !!org && !!contract, - }, - ) + const { + data: contractDetail, + isLoading: isLoadingContractDetail, + isError: isContractDetailError, + error: contractDetailError, + } = useQuery({ + ...managerOrganizationQueries.managerContractDetail({ + id: contract?.id ?? 0, + parent_lookup_organization: org?.id ?? 0, + }), + enabled: !!org && !!contract, + }) const { data: codes, @@ -462,6 +465,14 @@ const ContractAdminPageInternal: React.FC = ({ return } + if (isContractDetailError) { + const status = (contractDetailError as AxiosError)?.response?.status + if (status === 403 || status === 401) { + return + } + return + } + if (!org) { return } @@ -526,16 +537,18 @@ const ContractAdminPageInternal: React.FC = ({ "Redeemed on", "Last sent", ]) - const dataRows = rows.map((c) => - buildCsvRow([ - c.assigned_to, - c.redeemed_by, - c.redemption_status === "redeemed" ? "Redeemed" : "Pending claim", - formatDate(c.assigned_on), - formatDate(c.redeemed_on), - formatDate(c.last_sent), - ]), - ) + const dataRows = rows + .filter((c) => c.redemption_status !== "unassigned") + .map((c) => + buildCsvRow([ + c.assigned_to, + c.redeemed_by, + c.redemption_status === "redeemed" ? "Redeemed" : "Pending claim", + formatDate(c.assigned_on), + formatDate(c.redeemed_on), + formatDate(c.last_sent), + ]), + ) const csv = [header, ...dataRows].join("\n") const blob = new Blob([csv], { type: "text/csv" }) const url = URL.createObjectURL(blob) From 79cb0f0886ab13026711a885518f2b60ac70f790 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Fri, 26 Jun 2026 17:10:35 -0400 Subject: [PATCH 08/18] add sample CSV template and wire up download link for seat assignment import --- frontends/api/package.json | 2 +- frontends/main/package.json | 2 +- frontends/main/public/sample-seat-assignments.csv | 6 ++++++ .../app-pages/ContractAdminPage/AssignSeatsSection.tsx | 8 ++++++-- yarn.lock | 10 +++++----- 5 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 frontends/main/public/sample-seat-assignments.csv diff --git a/frontends/api/package.json b/frontends/api/package.json index 7a25d1a0fe..c09cb16619 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -32,7 +32,7 @@ "ol-test-utilities": "0.0.0" }, "dependencies": { - "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7dbc25d/src/typescript/mitxonline-api-axios/package.tgz", + "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes-client-443be46/src/typescript/mitxonline-api-axios/package.tgz", "@tanstack/react-query": "^5.66.0", "axios": "^1.12.2", "tiny-invariant": "^1.3.3" diff --git a/frontends/main/package.json b/frontends/main/package.json index 6cfe269a24..668aac6543 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -16,7 +16,7 @@ "@floating-ui/react": "^0.27.16", "@mitodl/arithmix": "^0.2.2", "@mitodl/course-search-utils": "^3.5.2", - "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7dbc25d/src/typescript/mitxonline-api-axios/package.tgz", + "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes-client-443be46/src/typescript/mitxonline-api-axios/package.tgz", "@mitodl/smoot-design": "^6.27.0", "@mui/base": "5.0.0-beta.70", "@mui/material": "^6.4.5", diff --git a/frontends/main/public/sample-seat-assignments.csv b/frontends/main/public/sample-seat-assignments.csv new file mode 100644 index 0000000000..1e3c921183 --- /dev/null +++ b/frontends/main/public/sample-seat-assignments.csv @@ -0,0 +1,6 @@ +id,name,email +1,Test User One,testuser1@example.com +2,Test User Two,testuser2@example.com +3,Test User Three,testuser3@example.com +4,Test User Four,testuser4@example.com +5,Test User Five,testuser5@example.com diff --git a/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.tsx b/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.tsx index 648865395c..59ddb8e3d7 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.tsx @@ -540,8 +540,12 @@ const AssignSeatsSection: React.FC = ({ > import from CSV - {/* TODO: replace href with a real sample CSV once we have one */} - + (download sample CSV) diff --git a/yarn.lock b/yarn.lock index 81448a9b77..96896340f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3344,13 +3344,13 @@ __metadata: languageName: node linkType: hard -"@mitodl/mitxonline-api-axios@https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7dbc25d/src/typescript/mitxonline-api-axios/package.tgz": +"@mitodl/mitxonline-api-axios@https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes-client-443be46/src/typescript/mitxonline-api-axios/package.tgz": version: 2026.6.24 - resolution: "@mitodl/mitxonline-api-axios@https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7dbc25d/src/typescript/mitxonline-api-axios/package.tgz" + resolution: "@mitodl/mitxonline-api-axios@https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes-client-443be46/src/typescript/mitxonline-api-axios/package.tgz" dependencies: "@types/node": "npm:^20.11.19" axios: "npm:^1.6.5" - checksum: 10/324d0b95622278848e99b7d60b73d204ef43a8043b9f7105682fc72372a6047980cb43cec7d36d2a0d4c544e4d2351814ffd49f20a4ab43a351d67fc03331e29 + checksum: 10/231c1eacf79f382aad264b8c837431f564192babe26d2a823cee674c814965a771f3e5c06015c7ca3ddda67830b64bf574be0dbd96b468702d2cdf2fcd418039 languageName: node linkType: hard @@ -8953,7 +8953,7 @@ __metadata: resolution: "api@workspace:frontends/api" dependencies: "@faker-js/faker": "npm:^10.0.0" - "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7dbc25d/src/typescript/mitxonline-api-axios/package.tgz" + "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes-client-443be46/src/typescript/mitxonline-api-axios/package.tgz" "@tanstack/react-query": "npm:^5.66.0" "@testing-library/react": "npm:^16.3.0" axios: "npm:^1.12.2" @@ -16180,7 +16180,7 @@ __metadata: "@happy-dom/jest-environment": "npm:^20.1.0" "@mitodl/arithmix": "npm:^0.2.2" "@mitodl/course-search-utils": "npm:^3.5.2" - "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes_means_paginated_problems-client-7dbc25d/src/typescript/mitxonline-api-axios/package.tgz" + "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes-client-443be46/src/typescript/mitxonline-api-axios/package.tgz" "@mitodl/smoot-design": "npm:^6.27.0" "@mui/base": "npm:5.0.0-beta.70" "@mui/material": "npm:^6.4.5" From c6b101fa9c184dc45a0ac82cc53cda60d33ef123 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Mon, 29 Jun 2026 09:41:15 -0400 Subject: [PATCH 09/18] simplify contractCodes query key --- .../mitxonline/hooks/organizations/queries.ts | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/frontends/api/src/mitxonline/hooks/organizations/queries.ts b/frontends/api/src/mitxonline/hooks/organizations/queries.ts index 3b17081171..30804e7dd2 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/queries.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/queries.ts @@ -56,22 +56,15 @@ const managerOrganizationKeys = { ] as const, contractCodesRoot: () => ["mitxonline", "manager", "organizations", "contracts", "codes"] as const, - // Prefix key used for invalidation — matches all pages/search states for one contract - contractCodesForContract: (id: number, orgId: number) => - [...managerOrganizationKeys.contractCodesRoot(), id, orgId] as const, contractCodes: ( opts: B2bApiB2bManagerOrganizationsContractsCodesListRequest, - ) => - [ - ...managerOrganizationKeys.contractCodesForContract( - opts.id, - opts.parent_lookup_organization, - ), - opts.page, - opts.page_size, - opts.search_term, - opts.status, - ] as const, + ) => [...managerOrganizationKeys.contractCodesRoot(), opts] as const, + // Prefix key used for invalidation — matches all pages/search states for one contract + contractCodesForContract: (id: number, orgId: number) => + managerOrganizationKeys.contractCodes({ + id, + parent_lookup_organization: orgId, + }), } const managerOrganizationQueries = { From 49f6e70edb5049732fed2f27f988385bdf581cb4 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Mon, 29 Jun 2026 09:43:05 -0400 Subject: [PATCH 10/18] remove not helpful comment --- .../src/app-pages/ContractAdminPage/AssignSeatsSection.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.test.tsx b/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.test.tsx index 5aa9de1f41..b5c59d187a 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.test.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.test.tsx @@ -83,7 +83,6 @@ describe("AssignSeatsSection", () => { />, ) - // The download link is a real anchor — in the tab order and not disabled const downloadLink = screen.getByRole("link", { name: "(download sample CSV)", }) From f5ae439a35093746473a6d3855f3d8faf8b57dee Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Mon, 29 Jun 2026 10:02:18 -0400 Subject: [PATCH 11/18] fix pagination flicker --- .../ContractAdminPage/ContractAdminPage.tsx | 43 ++++++++----------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx index 1f72fbda4e..e9c1637b56 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx @@ -2,7 +2,11 @@ import React, { useEffect, useRef, useState } from "react" import Image from "next/image" -import { useQuery } from "@tanstack/react-query" +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query" import { useFeatureFlagEnabled } from "posthog-js/react" import { alpha, @@ -26,7 +30,6 @@ import { import { AssignSeatsSection } from "./AssignSeatsSection" import { RowActionMenu } from "./RowActionMenu" import { managerOrganizationQueries } from "api/mitxonline-hooks/organizations" -import { b2bApi } from "api/mitxonline-clients" import type { AxiosError } from "axios" import { matchOrganizationBySlug } from "@/common/utils" import { ForbiddenError } from "@/common/errors" @@ -338,10 +341,13 @@ type ContractAdminPageInternalProps = { contractSlug: string } +const CODES_PAGE_SIZE = 25 + const ContractAdminPageInternal: React.FC = ({ orgSlug, contractSlug, }) => { + const queryClient = useQueryClient() const [statusFilter, setStatusFilter] = useState("all") const [searchQuery, setSearchQuery] = useState("") const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("") @@ -413,6 +419,7 @@ const ContractAdminPageInternal: React.FC = ({ id: contract?.id ?? 0, parent_lookup_organization: org?.id ?? 0, page, + page_size: CODES_PAGE_SIZE, search_term: debouncedSearchQuery || undefined, status: statusFilter === "redeemed" @@ -422,6 +429,7 @@ const ContractAdminPageInternal: React.FC = ({ : undefined, }), enabled: !!org && !!contract, + placeholderData: keepPreviousData, }) // Announce the result count after the query settles following a search change. @@ -489,13 +497,7 @@ const ContractAdminPageInternal: React.FC = ({ const pageResults = codes?.results ?? [] const totalCount = codes?.count ?? 0 - // Derive total pages from the response: if there's a next page we're on a - // full page so results.length equals the backend page size; otherwise this - // is the last page so totalPages equals the current page number. - const totalPages = - codes?.next && pageResults.length > 0 - ? Math.ceil(totalCount / pageResults.length) - : page + const totalPages = Math.ceil(totalCount / CODES_PAGE_SIZE) const handleTabChange = (_: React.SyntheticEvent, val: StatusFilter) => { setStatusFilter(val) @@ -510,25 +512,14 @@ const ContractAdminPageInternal: React.FC = ({ if (!totalPurchased) return setIsExporting(true) try { - const PAGE_SIZE = 500 - let page = 1 - let res = await b2bApi.b2bManagerOrganizationsContractsCodesList({ - id: contract.id, - parent_lookup_organization: org.id, - page, - page_size: PAGE_SIZE, - }) - const rows = [...res.data.results] - while (res.data.next) { - page += 1 - res = await b2bApi.b2bManagerOrganizationsContractsCodesList({ + const data = await queryClient.fetchQuery( + managerOrganizationQueries.managerContractCodes({ id: contract.id, parent_lookup_organization: org.id, - page, - page_size: PAGE_SIZE, - }) - rows.push(...res.data.results) - } + page_size: totalPurchased, + }), + ) + const rows = data.results const header = buildCsvRow([ "Assigned to", "Redeemed by", From 51f7bc93eeb3a84e1b367adcbb8b15e3700122f2 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Mon, 29 Jun 2026 10:59:13 -0400 Subject: [PATCH 12/18] fix tests: add page_size to codes mock URLs and paginate CSV expor --- .../ContractAdminPage.test.tsx | 12 ++++++- .../ContractAdminPage/ContractAdminPage.tsx | 31 +++++++++++++------ 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx index e08cb7bcbe..7a8e98dd4c 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx @@ -155,6 +155,7 @@ describe("ContractAdminPage", () => { setMockResponse.get( urls.contracts.managerContractCodes(org.id, contract.id, { page: 1, + page_size: 25, }), factories.contracts.paginatedContractCodes([]), ) @@ -180,6 +181,7 @@ describe("ContractAdminPage", () => { setMockResponse.get( urls.contracts.managerContractCodes(org.id, contract.id, { page: 1, + page_size: 25, }), factories.contracts.paginatedContractCodes([]), ) @@ -209,6 +211,7 @@ describe("ContractAdminPage", () => { setMockResponse.get( urls.contracts.managerContractCodes(org.id, contract.id, { page: 1, + page_size: 25, }), factories.contracts.paginatedContractCodes([]), ) @@ -263,12 +266,14 @@ describe("ContractAdminPage", () => { setMockResponse.get( urls.contracts.managerContractCodes(org.id, contract.id, { page: 1, + page_size: 25, }), factories.contracts.paginatedContractCodes([assignedCode, redeemedCode]), ) setMockResponse.get( urls.contracts.managerContractCodes(org.id, contract.id, { page: 1, + page_size: 25, status: "redeemed", }), factories.contracts.paginatedContractCodes([redeemedCode]), @@ -298,7 +303,10 @@ describe("ContractAdminPage", () => { makeContractDetail(contract, overrides), ) setMockResponse.get( - urls.contracts.managerContractCodes(org.id, contract.id, { page: 1 }), + urls.contracts.managerContractCodes(org.id, contract.id, { + page: 1, + page_size: 25, + }), factories.contracts.paginatedContractCodes([]), ) } @@ -463,12 +471,14 @@ describe("ContractAdminPage", () => { setMockResponse.get( urls.contracts.managerContractCodes(org.id, contract.id, { page: 1, + page_size: 25, }), factories.contracts.paginatedContractCodes([assignedCode, redeemedCode]), ) setMockResponse.get( urls.contracts.managerContractCodes(org.id, contract.id, { page: 1, + page_size: 25, status: "assigned", }), factories.contracts.paginatedContractCodes([assignedCode]), diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx index e9c1637b56..d1907133da 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx @@ -29,7 +29,10 @@ import { } from "@mitodl/smoot-design" import { AssignSeatsSection } from "./AssignSeatsSection" import { RowActionMenu } from "./RowActionMenu" -import { managerOrganizationQueries } from "api/mitxonline-hooks/organizations" +import { + managerOrganizationQueries, + type ContractCode, +} from "api/mitxonline-hooks/organizations" import type { AxiosError } from "axios" import { matchOrganizationBySlug } from "@/common/utils" import { ForbiddenError } from "@/common/errors" @@ -342,6 +345,7 @@ type ContractAdminPageInternalProps = { } const CODES_PAGE_SIZE = 25 +const CSV_EXPORT_PAGE_SIZE = 500 const ContractAdminPageInternal: React.FC = ({ orgSlug, @@ -512,14 +516,23 @@ const ContractAdminPageInternal: React.FC = ({ if (!totalPurchased) return setIsExporting(true) try { - const data = await queryClient.fetchQuery( - managerOrganizationQueries.managerContractCodes({ - id: contract.id, - parent_lookup_organization: org.id, - page_size: totalPurchased, - }), - ) - const rows = data.results + const allRows: ContractCode[] = [] + let page = 1 + let hasMore = true + while (hasMore) { + const data = await queryClient.fetchQuery( + managerOrganizationQueries.managerContractCodes({ + id: contract.id, + parent_lookup_organization: org.id, + page, + page_size: CSV_EXPORT_PAGE_SIZE, + }), + ) + allRows.push(...data.results) + hasMore = !!data.next + page++ + } + const rows = allRows const header = buildCsvRow([ "Assigned to", "Redeemed by", From ce50670eb61e75729a673ebf3b89abfa1e456ef1 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Tue, 30 Jun 2026 09:02:38 -0400 Subject: [PATCH 13/18] remove unused id and name columns from seat assignment CSV template --- frontends/main/public/sample-seat-assignments.csv | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontends/main/public/sample-seat-assignments.csv b/frontends/main/public/sample-seat-assignments.csv index 1e3c921183..03e5c78e21 100644 --- a/frontends/main/public/sample-seat-assignments.csv +++ b/frontends/main/public/sample-seat-assignments.csv @@ -1,6 +1,6 @@ -id,name,email -1,Test User One,testuser1@example.com -2,Test User Two,testuser2@example.com -3,Test User Three,testuser3@example.com -4,Test User Four,testuser4@example.com -5,Test User Five,testuser5@example.com +email +testuser1@example.com +testuser2@example.com +testuser3@example.com +testuser4@example.com +testuser5@example.com From 9ad2d542f1a303011546f693e13541afd7bd78c3 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Tue, 30 Jun 2026 10:49:36 -0400 Subject: [PATCH 14/18] removed unused export --- frontends/api/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/frontends/api/package.json b/frontends/api/package.json index c09cb16619..d43c7bf60c 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -16,7 +16,6 @@ "./test-utils/mockAxios": "./src/test-utils/mockAxios.ts", "./test-utils": "./src/test-utils/index.ts", "./mitxonline-hooks/*": "./src/mitxonline/hooks/*/index.ts", - "./mitxonline-clients": "./src/mitxonline/clients.ts", "./mitxonline-test-utils": "./src/mitxonline/test-utils/index.ts" }, "peerDependencies": { From e66b99268d761be38d7500f4428ba853396cb28f Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Wed, 1 Jul 2026 09:07:03 -0400 Subject: [PATCH 15/18] bump @mitodl/mitxonline-api-axios to published 2026.6.30-1 --- frontends/api/package.json | 2 +- .../mitxonline/hooks/organizations/index.ts | 5 +++- .../mitxonline/hooks/organizations/queries.ts | 25 +++---------------- .../test-utils/factories/contracts.ts | 13 ++++------ frontends/main/package.json | 2 +- .../ContractAdminPage/ContractAdminPage.tsx | 4 +-- .../ContractAdminPage/RowActionMenu.tsx | 4 +-- .../DashboardPage/ContractContent.test.tsx | 21 +++++++++++++--- yarn.lock | 12 ++++----- 9 files changed, 42 insertions(+), 46 deletions(-) diff --git a/frontends/api/package.json b/frontends/api/package.json index d43c7bf60c..9986a8c1ef 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -31,7 +31,7 @@ "ol-test-utilities": "0.0.0" }, "dependencies": { - "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes-client-443be46/src/typescript/mitxonline-api-axios/package.tgz", + "@mitodl/mitxonline-api-axios": "2026.6.30-1", "@tanstack/react-query": "^5.66.0", "axios": "^1.12.2", "tiny-invariant": "^1.3.3" diff --git a/frontends/api/src/mitxonline/hooks/organizations/index.ts b/frontends/api/src/mitxonline/hooks/organizations/index.ts index 19f9ea0743..4b051a2bac 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/index.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/index.ts @@ -115,4 +115,7 @@ export { useRemindCode, useRevokeCode, } -export type { ContractCode, PaginatedContractCodes } from "./queries" +export type { + ManagerEnrollmentCode, + PaginatedManagerEnrollmentCodeList, +} from "@mitodl/mitxonline-api-axios/v2" diff --git a/frontends/api/src/mitxonline/hooks/organizations/queries.ts b/frontends/api/src/mitxonline/hooks/organizations/queries.ts index 30804e7dd2..38b4a10e96 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/queries.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/queries.ts @@ -3,25 +3,12 @@ import { b2bApi } from "../../clients" import { OrganizationPage, ManagerContractDetail, - ManagerEnrollmentCode, PaginatedManagerEnrollmentCodeList, - PaginatedOrganizationPageList, B2bApiB2bOrganizationsRetrieveRequest, B2bApiB2bManagerOrganizationsContractsRetrieveRequest, B2bApiB2bManagerOrganizationsContractsCodesListRequest, } from "@mitodl/mitxonline-api-axios/v2" -type ContractCode = ManagerEnrollmentCode & { - redemption_status: "unassigned" | "assigned" | "redeemed" -} - -type PaginatedContractCodes = Omit< - PaginatedManagerEnrollmentCodeList, - "results" -> & { - results: ContractCode[] -} - const organizationKeys = { root: ["mitxonline", "organizations"], organizationsRetrieve: (opts?: B2bApiB2bOrganizationsRetrieveRequest) => [ @@ -72,12 +59,7 @@ const managerOrganizationQueries = { queryOptions({ queryKey: managerOrganizationKeys.list(), queryFn: async (): Promise => - b2bApi.b2bManagerOrganizationsList().then((res) => { - const data = res.data as - | PaginatedOrganizationPageList - | OrganizationPage[] - return Array.isArray(data) ? data : data.results - }), + b2bApi.b2bManagerOrganizationsList().then((res) => res.data.results), }), managerContractDetail: ( opts: B2bApiB2bManagerOrganizationsContractsRetrieveRequest, @@ -94,10 +76,10 @@ const managerOrganizationQueries = { ) => queryOptions({ queryKey: managerOrganizationKeys.contractCodes(opts), - queryFn: async (): Promise => + queryFn: async (): Promise => b2bApi .b2bManagerOrganizationsContractsCodesList(opts) - .then((res) => res.data as PaginatedContractCodes), + .then((res) => res.data), }), } @@ -108,4 +90,3 @@ export { managerOrganizationKeys, } -export type { ContractCode, PaginatedContractCodes } diff --git a/frontends/api/src/mitxonline/test-utils/factories/contracts.ts b/frontends/api/src/mitxonline/test-utils/factories/contracts.ts index 6b6cd98ad6..0a03006fbc 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/contracts.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/contracts.ts @@ -3,19 +3,16 @@ import type { BulkAssignError, BulkAssignResult, ContractPage, + ManagerEnrollmentCode, + PaginatedManagerEnrollmentCodeList, } from "@mitodl/mitxonline-api-axios/v2" import { makePaginatedFactory } from "ol-test-utilities" -import type { - ContractCode, - PaginatedContractCodes, -} from "../../hooks/organizations" const contract = (overrides: Partial = {}): ContractPage => ({ id: faker.number.int(), contract_end: faker.date.future().toISOString(), contract_start: faker.date.past().toISOString(), description: faker.lorem.sentence(), - integration_type: "non-sso" as const, name: faker.company.name(), organization: faker.number.int(), slug: faker.lorem.slug(), @@ -30,16 +27,16 @@ const contract = (overrides: Partial = {}): ContractPage => ({ const contracts = makePaginatedFactory(contract) const paginatedContractCodes = ( - results: ContractCode[], + results: ManagerEnrollmentCode[], opts: { count?: number; next?: string | null; previous?: string | null } = {}, -): PaginatedContractCodes => ({ +): PaginatedManagerEnrollmentCodeList => ({ count: opts.count ?? results.length, next: opts.next ?? null, previous: opts.previous ?? null, results, }) -const contractCode = (overrides: Partial = {}): ContractCode => { +const contractCode = (overrides: Partial = {}): ManagerEnrollmentCode => { const status = overrides.redemption_status ?? "assigned" const isAssigned = status !== "unassigned" const isRedeemed = status === "redeemed" diff --git a/frontends/main/package.json b/frontends/main/package.json index 668aac6543..a2e1953c65 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -16,7 +16,7 @@ "@floating-ui/react": "^0.27.16", "@mitodl/arithmix": "^0.2.2", "@mitodl/course-search-utils": "^3.5.2", - "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/refs/heads/dansubak/202606_paginated_codes-client-443be46/src/typescript/mitxonline-api-axios/package.tgz", + "@mitodl/mitxonline-api-axios": "2026.6.30-1", "@mitodl/smoot-design": "^6.27.0", "@mui/base": "5.0.0-beta.70", "@mui/material": "^6.4.5", diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx index d1907133da..6c7f593d4e 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx @@ -31,7 +31,7 @@ import { AssignSeatsSection } from "./AssignSeatsSection" import { RowActionMenu } from "./RowActionMenu" import { managerOrganizationQueries, - type ContractCode, + type ManagerEnrollmentCode, } from "api/mitxonline-hooks/organizations" import type { AxiosError } from "axios" import { matchOrganizationBySlug } from "@/common/utils" @@ -516,7 +516,7 @@ const ContractAdminPageInternal: React.FC = ({ if (!totalPurchased) return setIsExporting(true) try { - const allRows: ContractCode[] = [] + const allRows: ManagerEnrollmentCode[] = [] let page = 1 let hasMore = true while (hasMore) { diff --git a/frontends/main/src/app-pages/ContractAdminPage/RowActionMenu.tsx b/frontends/main/src/app-pages/ContractAdminPage/RowActionMenu.tsx index 4d98816227..399634f775 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/RowActionMenu.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/RowActionMenu.tsx @@ -20,7 +20,7 @@ import { useRemindCode, useRevokeCode, } from "api/mitxonline-hooks/organizations" -import type { ContractCode } from "api/mitxonline-hooks/organizations" +import type { ManagerEnrollmentCode } from "api/mitxonline-hooks/organizations" import type { AxiosError } from "axios" const ActionMenuItem = styled(MenuItem)(({ theme }) => ({ @@ -65,7 +65,7 @@ const CopiedMenuItem = styled(ActionMenuItem)(({ theme }) => ({ type ActionSeverity = "success" | "error" type RowActionMenuProps = { - code: ContractCode + code: ManagerEnrollmentCode orgId: number contractId: number /** Surfaces action outcomes in the page-level result Alert. */ diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx index c28d1fa677..c47751533c 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx @@ -1413,7 +1413,12 @@ describe("ContractContent", () => { mockedUseFeatureFlagEnabled.mockImplementation(() => false) const { orgX } = setupProgramsAndCourses() - setMockResponse.get(managerOrganizationsUrl, [orgX]) + setMockResponse.get(managerOrganizationsUrl, { + count: 1, + next: null, + previous: null, + results: [orgX], + }) renderWithProviders( { // Return a different org — user is a manager elsewhere, not for orgX const otherOrg = factories.organizations.organization({}) - setMockResponse.get(managerOrganizationsUrl, [otherOrg]) + setMockResponse.get(managerOrganizationsUrl, { + count: 1, + next: null, + previous: null, + results: [otherOrg], + }) renderWithProviders( { ) const { orgX } = setupProgramsAndCourses() - setMockResponse.get(managerOrganizationsUrl, [orgX]) + setMockResponse.get(managerOrganizationsUrl, { + count: 1, + next: null, + previous: null, + results: [orgX], + }) renderWithProviders( Date: Wed, 1 Jul 2026 13:07:55 +0000 Subject: [PATCH 16/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- frontends/api/src/mitxonline/hooks/organizations/queries.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontends/api/src/mitxonline/hooks/organizations/queries.ts b/frontends/api/src/mitxonline/hooks/organizations/queries.ts index 38b4a10e96..f1bb23a5d2 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/queries.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/queries.ts @@ -89,4 +89,3 @@ export { managerOrganizationQueries, managerOrganizationKeys, } - From 2210dbfee84eb6e2f9e2e1d7a944cc9da424989d Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Wed, 1 Jul 2026 09:10:47 -0400 Subject: [PATCH 17/18] format fix --- .../api/src/mitxonline/test-utils/factories/contracts.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontends/api/src/mitxonline/test-utils/factories/contracts.ts b/frontends/api/src/mitxonline/test-utils/factories/contracts.ts index 0a03006fbc..0be1c360fc 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/contracts.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/contracts.ts @@ -36,7 +36,9 @@ const paginatedContractCodes = ( results, }) -const contractCode = (overrides: Partial = {}): ManagerEnrollmentCode => { +const contractCode = ( + overrides: Partial = {}, +): ManagerEnrollmentCode => { const status = overrides.redemption_status ?? "assigned" const isAssigned = status !== "unassigned" const isRedeemed = status === "redeemed" From bc2e3626fbdc9140d3560bd28c861e5181616eb7 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Wed, 1 Jul 2026 09:46:49 -0400 Subject: [PATCH 18/18] update manager orgs mocks to paginated response shape --- .../ContractAdminPage.test.tsx | 56 ++++++++++++++++--- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx index 7a8e98dd4c..7809438201 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx @@ -116,7 +116,12 @@ describe("ContractAdminPage", () => { mockedUseFeatureFlagEnabled.mockReturnValue(true) const otherOrg = factories.organizations.organization({}) - setMockResponse.get(managerOrgsUrl, [otherOrg]) + setMockResponse.get(managerOrgsUrl, { + count: 1, + next: null, + previous: null, + results: [otherOrg], + }) renderWithProviders( , @@ -130,7 +135,12 @@ describe("ContractAdminPage", () => { mockedUseFeatureFlagEnabled.mockReturnValue(true) const { org } = makeOrgWithContract() - setMockResponse.get(managerOrgsUrl, [org]) + setMockResponse.get(managerOrgsUrl, { + count: 1, + next: null, + previous: null, + results: [org], + }) renderWithProviders( { mockedUseFeatureFlagEnabled.mockReturnValue(true) const { org, contract } = makeOrgWithContract() - setMockResponse.get(managerOrgsUrl, [org]) + setMockResponse.get(managerOrgsUrl, { + count: 1, + next: null, + previous: null, + results: [org], + }) setMockResponse.get( managerContractDetailUrl(org.id, contract.id), makeContractDetail(contract), @@ -173,7 +188,12 @@ describe("ContractAdminPage", () => { mockedUseFeatureFlagEnabled.mockReturnValue(true) const { org, contract } = makeOrgWithContract() - setMockResponse.get(managerOrgsUrl, [org]) + setMockResponse.get(managerOrgsUrl, { + count: 1, + next: null, + previous: null, + results: [org], + }) setMockResponse.get( managerContractDetailUrl(org.id, contract.id), makeContractDetail(contract, { total_codes: 75, total_enrollments: 12 }), @@ -198,7 +218,12 @@ describe("ContractAdminPage", () => { mockedUseFeatureFlagEnabled.mockReturnValue(true) const { org, contract } = makeOrgWithContract() - setMockResponse.get(managerOrgsUrl, [org]) + setMockResponse.get(managerOrgsUrl, { + count: 1, + next: null, + previous: null, + results: [org], + }) setMockResponse.get( managerContractDetailUrl(org.id, contract.id), makeContractDetail(contract, { @@ -241,7 +266,12 @@ describe("ContractAdminPage", () => { mockedUseFeatureFlagEnabled.mockReturnValue(true) const { org, contract } = makeOrgWithContract() - setMockResponse.get(managerOrgsUrl, [org]) + setMockResponse.get(managerOrgsUrl, { + count: 1, + next: null, + previous: null, + results: [org], + }) setMockResponse.get( managerContractDetailUrl(org.id, contract.id), makeContractDetail(contract, { @@ -297,7 +327,12 @@ describe("ContractAdminPage", () => { contract: ReturnType, overrides: Parameters[1] = {}, ) => { - setMockResponse.get(managerOrgsUrl, [org]) + setMockResponse.get(managerOrgsUrl, { + count: 1, + next: null, + previous: null, + results: [org], + }) setMockResponse.get( managerContractDetailUrl(org.id, contract.id), makeContractDetail(contract, overrides), @@ -446,7 +481,12 @@ describe("ContractAdminPage", () => { mockedUseFeatureFlagEnabled.mockReturnValue(true) const { org, contract } = makeOrgWithContract() - setMockResponse.get(managerOrgsUrl, [org]) + setMockResponse.get(managerOrgsUrl, { + count: 1, + next: null, + previous: null, + results: [org], + }) setMockResponse.get( managerContractDetailUrl(org.id, contract.id), makeContractDetail(contract, {