diff --git a/frontends/api/package.json b/frontends/api/package.json index ae1b490837..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": "2026.6.17", + "@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 6be06ed91f..4b051a2bac 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,7 @@ export { useRemindCode, useRevokeCode, } -export type { ContractCode } 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 418cd7ab9d..f1bb23a5d2 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/queries.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/queries.ts @@ -3,16 +3,12 @@ import { b2bApi } from "../../clients" import { OrganizationPage, ManagerContractDetail, - ManagerEnrollmentCode, + PaginatedManagerEnrollmentCodeList, B2bApiB2bOrganizationsRetrieveRequest, B2bApiB2bManagerOrganizationsContractsRetrieveRequest, B2bApiB2bManagerOrganizationsContractsCodesListRequest, } from "@mitodl/mitxonline-api-axios/v2" -type ContractCode = ManagerEnrollmentCode & { - redemption_status: "unassigned" | "assigned" | "redeemed" -} - const organizationKeys = { root: ["mitxonline", "organizations"], organizationsRetrieve: (opts?: B2bApiB2bOrganizationsRetrieveRequest) => [ @@ -50,6 +46,12 @@ const managerOrganizationKeys = { contractCodes: ( opts: B2bApiB2bManagerOrganizationsContractsCodesListRequest, ) => [...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 = { @@ -57,7 +59,7 @@ const managerOrganizationQueries = { queryOptions({ queryKey: managerOrganizationKeys.list(), queryFn: async (): Promise => - b2bApi.b2bManagerOrganizationsList().then((res) => res.data), + b2bApi.b2bManagerOrganizationsList().then((res) => res.data.results), }), managerContractDetail: ( opts: B2bApiB2bManagerOrganizationsContractsRetrieveRequest, @@ -74,10 +76,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), }), } @@ -87,5 +89,3 @@ export { managerOrganizationQueries, managerOrganizationKeys, } - -export type { ContractCode } diff --git a/frontends/api/src/mitxonline/test-utils/factories/contracts.ts b/frontends/api/src/mitxonline/test-utils/factories/contracts.ts index a0bbc3c6d5..0be1c360fc 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/contracts.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/contracts.ts @@ -3,16 +3,16 @@ import type { BulkAssignError, BulkAssignResult, ContractPage, + ManagerEnrollmentCode, + PaginatedManagerEnrollmentCodeList, } from "@mitodl/mitxonline-api-axios/v2" import { makePaginatedFactory } from "ol-test-utilities" -import type { ContractCode } 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(), @@ -26,7 +26,19 @@ const contract = (overrides: Partial = {}): ContractPage => ({ const contracts = makePaginatedFactory(contract) -const contractCode = (overrides: Partial = {}): ContractCode => { +const paginatedContractCodes = ( + results: ManagerEnrollmentCode[], + opts: { count?: number; next?: string | null; previous?: string | null } = {}, +): PaginatedManagerEnrollmentCodeList => ({ + count: opts.count ?? results.length, + next: opts.next ?? null, + previous: opts.previous ?? null, + results, +}) + +const contractCode = ( + overrides: Partial = {}, +): ManagerEnrollmentCode => { const status = overrides.redemption_status ?? "assigned" const isAssigned = status !== "unassigned" const isRedeemed = status === "redeemed" @@ -61,4 +73,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..9fb471e41f 100644 --- a/frontends/api/src/mitxonline/test-utils/urls.ts +++ b/frontends/api/src/mitxonline/test-utils/urls.ts @@ -90,8 +90,24 @@ 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 + 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 + ? 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..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": "2026.6.17", + "@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/public/sample-seat-assignments.csv b/frontends/main/public/sample-seat-assignments.csv new file mode 100644 index 0000000000..03e5c78e21 --- /dev/null +++ b/frontends/main/public/sample-seat-assignments.csv @@ -0,0 +1,6 @@ +email +testuser1@example.com +testuser2@example.com +testuser3@example.com +testuser4@example.com +testuser5@example.com diff --git a/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.test.tsx b/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.test.tsx index 793aaba7ff..b5c59d187a 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( @@ -68,22 +73,30 @@ 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", { + 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", () => { renderWithProviders( - , + , ) const importButton = screen.getByRole("button", { name: "import from CSV" }) @@ -94,7 +107,12 @@ describe("AssignSeatsSection", () => { test("Assign Seats button is disabled when textarea is empty", () => { renderWithProviders( - , + , ) expect(screen.getByRole("button", { name: "Assign Seats" })).toBeDisabled() @@ -102,7 +120,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 +136,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 +154,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 +170,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 +188,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 +206,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 +225,12 @@ describe("AssignSeatsSection", () => { test("shows valid/invalid counts after entering emails", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -189,7 +242,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 +259,12 @@ describe("AssignSeatsSection", () => { test("validation badge is not shown when textarea is empty", () => { renderWithProviders( - , + , ) expect(screen.queryByText(/valid/i)).not.toBeInTheDocument() @@ -209,7 +272,12 @@ describe("AssignSeatsSection", () => { test("live region announces email validation summary after debounce", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -229,7 +297,12 @@ describe("AssignSeatsSection", () => { test("clicking Assign Seats opens the confirm modal", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -245,7 +318,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 +336,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 +356,12 @@ describe("AssignSeatsSection", () => { test("closing the modal hides it", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -294,7 +382,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 +411,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 +434,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 +458,12 @@ describe("AssignSeatsSection", () => { test("importing a CSV with no-@ rows shows skipped count in modal", async () => { renderWithProviders( - , + , ) const csvContent = @@ -375,7 +483,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 +510,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 +538,12 @@ describe("AssignSeatsSection", () => { test("accepts newline-separated emails", async () => { renderWithProviders( - , + , ) const textarea = screen.getByPlaceholderText(/enter employee emails/i) @@ -462,6 +585,7 @@ describe("AssignSeatsSection", () => { orgId={ORG_ID} contractId={CONTRACT_ID} availableSeats={50} + isLoadingSeats={false} />, ) @@ -492,6 +616,7 @@ describe("AssignSeatsSection", () => { orgId={ORG_ID} contractId={CONTRACT_ID} availableSeats={50} + isLoadingSeats={false} />, ) @@ -514,6 +639,7 @@ describe("AssignSeatsSection", () => { orgId={ORG_ID} contractId={CONTRACT_ID} availableSeats={50} + isLoadingSeats={false} />, ) diff --git a/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.tsx b/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.tsx index a98b4dde90..59ddb8e3d7 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 +748,9 @@ const ContractAdminPageInternal: React.FC = ({ {isLoadingCodes ? "Loading seat assignments" - : filteredCodes.length === 0 + : pageResults.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 +762,7 @@ const ContractAdminPageInternal: React.FC = ({ ))} - ) : pagedCodes.length === 0 ? ( + ) : pageResults.length === 0 ? ( = ({ ) : ( - pagedCodes.map((code) => ( + pageResults.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..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. */ @@ -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,13 +137,13 @@ const RowActionMenu: React.FC = ({ id: contractId, parent_lookup_organization: orgId, }) - onResult(isRedeemed ? "Learner uninvited." : "Seat released.", "success") + onResult("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.", + ? "This seat has already been redeemed and cannot be released." + : "Could not release the seat. Please try again.", "error", ) } @@ -223,45 +223,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 +261,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.`}

{ 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(