From 060a9bdfa5e998d0e6a296d1faed964e5fd4c74e Mon Sep 17 00:00:00 2001 From: Devasia Joseph Date: Mon, 10 Aug 2026 13:44:00 +0530 Subject: [PATCH] feat: bulk unenroll UI for support tools (LP-860) --- src/CourseBulkUnenroll/BatchListPanel.jsx | 199 ++++++ src/CourseBulkUnenroll/BatchMetadata.jsx | 62 ++ src/CourseBulkUnenroll/ConfirmModal.jsx | 100 +++ .../CourseBulkUnenrollBatchList.test.jsx | 317 +++++++++ .../CourseBulkUnenrollBatchesPage.jsx | 56 ++ .../CourseBulkUnenrollIndexPage.jsx | 313 +++++++++ .../CourseBulkUnenrollIndexPage.test.jsx | 635 ++++++++++++++++++ .../CourseBulkUnenrollPolling.test.jsx | 135 ++++ src/CourseBulkUnenroll/PreviewPanel.jsx | 171 +++++ src/CourseBulkUnenroll/ProgressPanel.jsx | 283 ++++++++ .../StateFilterDropdown.jsx | 70 ++ src/CourseBulkUnenroll/TableActions.jsx | 53 ++ src/CourseBulkUnenroll/UploadPanel.jsx | 99 +++ src/CourseBulkUnenroll/constants.js | 145 ++++ src/CourseBulkUnenroll/data/api.js | 173 +++++ src/CourseBulkUnenroll/data/api.test.js | 202 ++++++ src/CourseBulkUnenroll/data/hooks.js | 200 ++++++ src/CourseBulkUnenroll/data/hooks.test.jsx | 224 ++++++ src/CourseBulkUnenroll/index.scss | 103 +++ src/CourseBulkUnenroll/messages.js | 455 +++++++++++++ src/CourseBulkUnenroll/utils.js | 54 ++ src/data/constants/routes.js | 2 + src/index.jsx | 10 + src/index.scss | 2 + src/supportHeader/Header.jsx | 1 + 25 files changed, 4064 insertions(+) create mode 100644 src/CourseBulkUnenroll/BatchListPanel.jsx create mode 100644 src/CourseBulkUnenroll/BatchMetadata.jsx create mode 100644 src/CourseBulkUnenroll/ConfirmModal.jsx create mode 100644 src/CourseBulkUnenroll/CourseBulkUnenrollBatchList.test.jsx create mode 100644 src/CourseBulkUnenroll/CourseBulkUnenrollBatchesPage.jsx create mode 100644 src/CourseBulkUnenroll/CourseBulkUnenrollIndexPage.jsx create mode 100644 src/CourseBulkUnenroll/CourseBulkUnenrollIndexPage.test.jsx create mode 100644 src/CourseBulkUnenroll/CourseBulkUnenrollPolling.test.jsx create mode 100644 src/CourseBulkUnenroll/PreviewPanel.jsx create mode 100644 src/CourseBulkUnenroll/ProgressPanel.jsx create mode 100644 src/CourseBulkUnenroll/StateFilterDropdown.jsx create mode 100644 src/CourseBulkUnenroll/TableActions.jsx create mode 100644 src/CourseBulkUnenroll/UploadPanel.jsx create mode 100644 src/CourseBulkUnenroll/constants.js create mode 100644 src/CourseBulkUnenroll/data/api.js create mode 100644 src/CourseBulkUnenroll/data/api.test.js create mode 100644 src/CourseBulkUnenroll/data/hooks.js create mode 100644 src/CourseBulkUnenroll/data/hooks.test.jsx create mode 100644 src/CourseBulkUnenroll/index.scss create mode 100644 src/CourseBulkUnenroll/messages.js create mode 100644 src/CourseBulkUnenroll/utils.js diff --git a/src/CourseBulkUnenroll/BatchListPanel.jsx b/src/CourseBulkUnenroll/BatchListPanel.jsx new file mode 100644 index 000000000..a47e79ea2 --- /dev/null +++ b/src/CourseBulkUnenroll/BatchListPanel.jsx @@ -0,0 +1,199 @@ +import { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Alert, Badge, Button, DataTable, Spinner, Stack, +} from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; +import StateFilterDropdown from './StateFilterDropdown'; +import TableActions from './TableActions'; +import { usePolledBatchList } from './data/hooks'; +import { + ALL_FILTER_VALUE, BATCH_STATE_FILTER_OPTIONS, BATCH_STATE_VARIANTS, TABLE_PAGE_SIZE, +} from './constants'; +import { formatTimestamp, stateLabel } from './utils'; + +// Cell renderers live at module scope: defined inside the parent they would be a +// new component type on every render, and React would tear down the rows' DOM. + +const rowShape = PropTypes.shape({ + original: PropTypes.shape({ + batch_id: PropTypes.string, + state: PropTypes.string, + created: PropTypes.string, + }).isRequired, +}).isRequired; + +function StateCell({ row }) { + const intl = useIntl(); + const { state } = row.original; + return ( + + {stateLabel(intl, state)} + + ); +} +StateCell.propTypes = { row: rowShape }; + +function StartedCell({ row }) { + const intl = useIntl(); + return formatTimestamp(intl, row.original.created); +} +StartedCell.propTypes = { row: rowShape }; + +// `onOpen` rides on the column definition — react-table merges custom keys into +// the column instance, which is how a module-scope cell reaches a page callback. +function OpenCell({ row, column }) { + const intl = useIntl(); + return ( + + ); +} +OpenCell.propTypes = { + row: rowShape, + column: PropTypes.shape({ onOpen: PropTypes.func.isRequired }).isRequired, +}; + +/** + * Every batch, newest first — the run history. Rendered by + * `CourseBulkUnenrollBatchesPage`, which owns the route. + * + * Unfiltered by default on purpose: this is the only place a finished or + * cancelled batch can be found, since the batch id is surfaced just once (in the + * URL after confirming) and a run can take hours. The status column carries the + * distinction that a default filter would otherwise hide. + * + * Shows every operator's batches, not just the current user's — a run you cannot + * find because a colleague started it is the same operational problem. + */ +export default function BatchListPanel({ onOpen }) { + const intl = useIntl(); + const [filter, setFilter] = useState(ALL_FILTER_VALUE); + const [tableState, setTableState] = useState({ pageIndex: 0, pageSize: TABLE_PAGE_SIZE }); + + // An empty filter means "all": send no param rather than an empty one. + const { + batches, count, error, isLoading, + } = usePolledBatchList(filter || undefined, tableState.pageIndex + 1); + + // This panel owns its own page now, so both of these states have to say + // something. Previously it sat under a file picker and could stay silent. + if (error) { + return ( + + {error.error?.[0]?.text ?? intl.formatMessage(messages.listError)} + + ); + } + + // `batches` is null until the first response, which is distinct from empty. + // A blank page here would read as a broken route. + if (batches === null) { + return ( +
+ + {intl.formatMessage(messages.batchListHeading, { count: 0 })} +
+ ); + } + + const tableActions = ( + + { + setFilter(value); + setTableState((prev) => ({ ...prev, pageIndex: 0 })); + }} + options={BATCH_STATE_FILTER_OPTIONS} + /> + + ); + + return ( +
+ +
+ {intl.formatMessage(messages.batchListHeading, { count })} +
+ {isLoading && } +
+
+

{intl.formatMessage(messages.batchListDescription)}

+
+ +
+ + + {batches.length > 0 && } + {batches.length === 0 && ( +
+ {/* An empty filtered result is an answer; an empty unfiltered one + means nothing has ever run. Different facts, different wording. */} + {intl.formatMessage(filter ? messages.noBatches : messages.noBatchesYet)} +
+ )} + {count > 0 && } +
+
+
+ ); +} + +BatchListPanel.propTypes = { + onOpen: PropTypes.func.isRequired, +}; diff --git a/src/CourseBulkUnenroll/BatchMetadata.jsx b/src/CourseBulkUnenroll/BatchMetadata.jsx new file mode 100644 index 000000000..fcaf0ca45 --- /dev/null +++ b/src/CourseBulkUnenroll/BatchMetadata.jsx @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; +import { TERMINAL_BATCH_STATES } from './constants'; +import { formatTimestamp } from './utils'; + +/** + * Who ran this batch, why, and when it started and stopped. + * + * Split out of ProgressPanel, which already carries the progress bar, the totals, + * the actions and the course table. The reason matters most: it is the audit + * trail for an irreversible action, and until now it was collected at confirm + * time and then never shown back. + * + * `modified` is labelled "Finished" only for a terminal batch — for a live one it + * is just the last time the worker touched the row. + */ +export default function BatchMetadata({ batch }) { + const intl = useIntl(); + const isFinished = TERMINAL_BATCH_STATES.includes(batch.state); + + const rows = [ + [messages.detailsReason, batch.reason || intl.formatMessage(messages.detailsNoReason)], + [messages.detailsRequester, batch.requester || '—'], + [messages.detailsFile, batch.csv_filename || '—'], + [messages.detailsStarted, formatTimestamp(intl, batch.created)], + [ + isFinished ? messages.detailsFinished : messages.detailsLastUpdated, + formatTimestamp(intl, batch.modified), + ], + [messages.detailsBatchId, batch.batch_id], + ]; + + return ( +
+

{intl.formatMessage(messages.detailsHeading)}

+
+ {rows.map(([label, value]) => ( +
+
+ {intl.formatMessage(label)} +
+
{value}
+
+ ))} +
+
+ ); +} + +BatchMetadata.propTypes = { + batch: PropTypes.shape({ + batch_id: PropTypes.string, + state: PropTypes.string, + reason: PropTypes.string, + requester: PropTypes.string, + csv_filename: PropTypes.string, + created: PropTypes.string, + modified: PropTypes.string, + }).isRequired, +}; diff --git a/src/CourseBulkUnenroll/ConfirmModal.jsx b/src/CourseBulkUnenroll/ConfirmModal.jsx new file mode 100644 index 000000000..7bed2c188 --- /dev/null +++ b/src/CourseBulkUnenroll/ConfirmModal.jsx @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import { + Icon, ModalCloseButton, ModalLayer, StatefulButton, +} from '@openedx/paragon'; +import { CheckCircleOutline, SpinnerSimple } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +/** + * The last line of defence before an irreversible action. + * + * Chrome follows CourseTeamManagement's `CoursesChangesModal`: the shared + * `change-confirm-modal` shell, a heading above a full-bleed divider, and a + * `ModalCloseButton` + `StatefulButton` pair in the footer. + * + * The learner count is deliberately oversized and kept from the earlier design: + * at this scale the difference between 4,200 and 4,200,000 is the whole decision, + * and a number set in body text is easy to skim past. + */ +export default function ConfirmModal({ + isOpen, onConfirm, onCancel, courseCount, learnerCount, submitState, positionRef, +}) { + const intl = useIntl(); + const isSettling = submitState === 'pending' || submitState === 'complete'; + + return ( + +
+
+

{intl.formatMessage(messages.confirmModalTitle)}

+
+
+ +

+ + {learnerCount.toLocaleString()} + +

+

+ {intl.formatMessage(messages.confirmModalBody, { + courses: courseCount.toLocaleString(), + })} +

+
+ +
+ + {intl.formatMessage(messages.confirmModalCancel)} + + , + complete: , + }} + labels={{ + default: intl.formatMessage(messages.confirmModalConfirm), + pending: intl.formatMessage(messages.confirmModalConfirming), + complete: intl.formatMessage(messages.confirmModalConfirmed), + }} + /> +
+
+ + ); +} + +ConfirmModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onConfirm: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + courseCount: PropTypes.number.isRequired, + learnerCount: PropTypes.number.isRequired, + submitState: PropTypes.string.isRequired, + positionRef: PropTypes.shape({ current: PropTypes.instanceOf(Element) }), +}; + +ConfirmModal.defaultProps = { + positionRef: undefined, +}; diff --git a/src/CourseBulkUnenroll/CourseBulkUnenrollBatchList.test.jsx b/src/CourseBulkUnenroll/CourseBulkUnenrollBatchList.test.jsx new file mode 100644 index 000000000..92c921e79 --- /dev/null +++ b/src/CourseBulkUnenroll/CourseBulkUnenrollBatchList.test.jsx @@ -0,0 +1,317 @@ +import '@testing-library/jest-dom'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +import CourseBulkUnenrollBatchesPage from './CourseBulkUnenrollBatchesPage'; +import CourseBulkUnenrollIndexPage from './CourseBulkUnenrollIndexPage'; +import UserMessagesProvider from '../userMessages/UserMessagesProvider'; +import * as api from './data/api'; + +jest.mock('./data/api', () => ({ + ...jest.requireActual('./data/api'), + uploadBulkUnenrollFile: jest.fn(), + confirmBulkUnenrollBatch: jest.fn(), + getBulkUnenrollBatchStatus: jest.fn(), + cancelBulkUnenrollBatch: jest.fn(), + retryBulkUnenrollBatch: jest.fn(), + listBulkUnenrollBatches: jest.fn(), +})); + +const BATCH_ID = 'a1b2c3d4-0000-0000-0000-000000000000'; +const OTHER_BATCH_ID = 'b2c3d4e5-0000-0000-0000-000000000000'; + +// Both routes are mounted so the tests can follow the real navigation between +// them: the history page is where the list lives, and opening a batch hands off +// to the upload page's progress view. +const renderAt = (path) => render( + + + + + } /> + } /> + + + + , +); + +const renderPage = () => renderAt('/course_bulk_unenroll/batches'); + +const batchRow = (overrides = {}) => ({ + batch_id: BATCH_ID, + state: 'running', + reason: 'partner offboarding', + requester: 'ops-admin', + csv_filename: 'q3.csv', + total_courses: 12, + created: '2026-07-28T10:00:00Z', + modified: '2026-07-28T12:30:00Z', + ...overrides, +}); + +const statusResponse = () => ({ + batch_id: BATCH_ID, + state: 'running', + reason: 'partner offboarding', + requester: 'ops-admin', + csv_filename: 'q3.csv', + total_courses: 12, + created: '2026-07-28T10:00:00Z', + modified: '2026-07-28T12:30:00Z', + totals: { + active: 100, unenrolled: 10, already_inactive: 0, failed: 0, courses_finished: 1, + }, + courses: { count: 0, results: [] }, +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('CourseBulkUnenrollIndexPage — batch list', () => { + it('lists batches, whoever started them', async () => { + api.listBulkUnenrollBatches.mockResolvedValue({ + count: 2, + results: [ + batchRow(), + batchRow({ + batch_id: OTHER_BATCH_ID, + state: 'pending', + reason: 'test run', + requester: 'other-admin', + csv_filename: 'test.csv', + }), + ], + }); + + renderPage(); + + const list = await screen.findByTestId('bulk-unenroll-batch-list'); + expect(list).toBeInTheDocument(); + expect(screen.getByText('partner offboarding')).toBeInTheDocument(); + expect(screen.getByText('test run')).toBeInTheDocument(); + expect(screen.getByText('q3.csv')).toBeInTheDocument(); + expect(screen.getByText('test.csv')).toBeInTheDocument(); + // A colleague's run is listed too — losing track of theirs is the same problem. + expect(screen.getByText('ops-admin')).toBeInTheDocument(); + expect(screen.getByText('other-admin')).toBeInTheDocument(); + }); + + it('asks for every batch rather than filtering by state', async () => { + api.listBulkUnenrollBatches.mockResolvedValue({ count: 0, results: [] }); + + renderPage(); + + // No state filter: a finished or cancelled batch has nowhere else to be seen. + await waitFor(() => expect(api.listBulkUnenrollBatches) + .toHaveBeenCalledWith({ state: undefined, page: 1 }, expect.anything())); + }); + + it('shows finished and cancelled batches alongside running ones', async () => { + api.listBulkUnenrollBatches.mockResolvedValue({ + count: 4, + results: [ + batchRow({ state: 'running' }), + batchRow({ batch_id: OTHER_BATCH_ID, state: 'succeeded', reason: 'done last week' }), + batchRow({ batch_id: 'c3d4e5f6-0000-0000-0000-000000000000', state: 'cancelled', reason: 'stopped early' }), + batchRow({ batch_id: 'd4e5f6a7-0000-0000-0000-000000000000', state: 'partial', reason: 'some failures' }), + ], + }); + + renderPage(); + + await screen.findByTestId('bulk-unenroll-batch-list'); + // States render as translated badge labels, not raw API values. + expect(screen.getByText('Running')).toBeInTheDocument(); + expect(screen.getByText('Cancelled')).toBeInTheDocument(); + expect(screen.getByText('Partial')).toBeInTheDocument(); + // "Succeeded" is also a filter choice, so it appears more than once. + expect(screen.getAllByText('Succeeded').length).toBeGreaterThan(0); + }); + + it('narrows the list to the batches a filter choice names', async () => { + api.listBulkUnenrollBatches.mockResolvedValue({ count: 1, results: [batchRow()] }); + + renderPage(); + await screen.findByTestId('bulk-unenroll-batch-list'); + + await userEvent.click(screen.getByTestId('bulk-unenroll-batch-filter')); + await userEvent.click(screen.getByTestId('bulk-unenroll-batch-filter-item-cancelled')); + + await waitFor(() => expect(api.listBulkUnenrollBatches) + .toHaveBeenCalledWith({ state: 'cancelled', page: 1 }, expect.anything())); + }); + + it('offers every batch status the table can display', async () => { + // A status visible in the Status column must be selectable. An earlier + // grouped menu ("in flight", "needs attention") left validated and cancelled + // — most of the real rows — with no way to select them. + api.listBulkUnenrollBatches.mockResolvedValue({ count: 1, results: [batchRow()] }); + + renderPage(); + await screen.findByTestId('bulk-unenroll-batch-list'); + await userEvent.click(screen.getByTestId('bulk-unenroll-batch-filter')); + + ['validated', 'pending', 'running', 'succeeded', 'partial', 'failed', 'cancelled'].forEach( + (state) => expect(screen.getByTestId(`bulk-unenroll-batch-filter-item-${state}`)).toBeInTheDocument(), + ); + }); + + it('stays visible with an empty-state message when a filter excludes everything', async () => { + // Distinct from "no batch has ever run": here the empty result is an answer, + // and hiding the panel would take the filter control away with it. + api.listBulkUnenrollBatches.mockResolvedValue({ count: 1, results: [batchRow()] }); + + renderPage(); + await screen.findByTestId('bulk-unenroll-batch-list'); + + api.listBulkUnenrollBatches.mockResolvedValue({ count: 0, results: [] }); + await userEvent.click(screen.getByTestId('bulk-unenroll-batch-filter')); + await userEvent.click(screen.getByTestId('bulk-unenroll-batch-filter-item-succeeded')); + + expect(await screen.findByTestId('bulk-unenroll-batch-list-empty')).toBeInTheDocument(); + expect(screen.getByTestId('bulk-unenroll-batch-list')).toBeInTheDocument(); + }); + + it('opens a finished batch from the list', async () => { + // The whole point of listing terminal batches: they are otherwise reachable + // only by an id that was surfaced once, in a URL. + api.listBulkUnenrollBatches.mockResolvedValue({ + count: 1, results: [batchRow({ state: 'succeeded' })], + }); + api.getBulkUnenrollBatchStatus.mockResolvedValue({ ...statusResponse(), state: 'succeeded' }); + + renderPage(); + await screen.findByTestId('bulk-unenroll-batch-list'); + + await userEvent.click(screen.getByTestId(`bulk-unenroll-open-${BATCH_ID}`)); + + expect(await screen.findByTestId('bulk-unenroll-progress')).toBeInTheDocument(); + }); + + it('opens a batch from the list', async () => { + api.listBulkUnenrollBatches.mockResolvedValue({ count: 1, results: [batchRow()] }); + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse()); + + renderPage(); + await screen.findByTestId('bulk-unenroll-batch-list'); + + await userEvent.click(screen.getByTestId(`bulk-unenroll-open-${BATCH_ID}`)); + + expect(await screen.findByTestId('bulk-unenroll-progress')).toBeInTheDocument(); + expect(api.getBulkUnenrollBatchStatus) + .toHaveBeenCalledWith(BATCH_ID, { state: undefined, page: 1 }, expect.anything()); + }); + + it('says so when no batch has ever been run', async () => { + // On its own page an empty result must be stated. Rendering nothing — which + // was right when this sat under the file picker — would look like a broken route. + api.listBulkUnenrollBatches.mockResolvedValue({ count: 0, results: [] }); + + renderPage(); + + expect(await screen.findByTestId('bulk-unenroll-batch-list-empty')).toBeInTheDocument(); + expect(screen.getByText(/no batches have been run yet/i)).toBeInTheDocument(); + }); + + it('offers a way back to the upload page', async () => { + api.listBulkUnenrollBatches.mockResolvedValue({ count: 1, results: [batchRow()] }); + + renderPage(); + await screen.findByTestId('bulk-unenroll-batch-list'); + + await userEvent.click(screen.getByTestId('bulk-unenroll-back-to-upload')); + + expect(await screen.findByTestId('bulk-unenroll-file-input')).toBeInTheDocument(); + }); + + it('reports how many of the total are on screen', async () => { + api.listBulkUnenrollBatches.mockResolvedValue({ count: 140, results: [batchRow()] }); + + renderPage(); + + const count = await screen.findByTestId('bulk-unenroll-table-count'); + expect(count).toHaveTextContent('140'); + }); + + it('fetches the next page from the server rather than paging a single response', async () => { + // The endpoint paginates, so without this wiring the footer's page buttons + // move a local index and the rows never change. + api.listBulkUnenrollBatches.mockResolvedValue({ + count: 140, + results: [batchRow()], + }); + + renderPage(); + await screen.findByTestId('bulk-unenroll-batch-list'); + + await userEvent.click(screen.getByLabelText(/next/i)); + + await waitFor(() => expect(api.listBulkUnenrollBatches) + .toHaveBeenCalledWith({ state: undefined, page: 2 }, expect.anything())); + }); + + it('reports a load failure instead of spinning forever', async () => { + // `batches` stays null on error, so without this branch the page would show + // its loading placeholder indefinitely. + api.listBulkUnenrollBatches.mockResolvedValue({ + isApiError: true, + error: [{ + code: null, dismissible: true, text: 'The list of batches could not be loaded.', type: 'danger', topic: 'courseBulkUnenrollApiErrors', + }], + }); + + renderPage(); + + expect(await screen.findByTestId('bulk-unenroll-batch-list-error')).toBeInTheDocument(); + expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument(); + expect(screen.queryByTestId('bulk-unenroll-batch-list-loading')).not.toBeInTheDocument(); + }); + + it('stops polling once a batch is opened', async () => { + api.listBulkUnenrollBatches.mockResolvedValue({ count: 1, results: [batchRow()] }); + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse()); + + renderPage(); + await screen.findByTestId('bulk-unenroll-batch-list'); + const callsBefore = api.listBulkUnenrollBatches.mock.calls.length; + + await userEvent.click(screen.getByTestId(`bulk-unenroll-open-${BATCH_ID}`)); + await screen.findByTestId('bulk-unenroll-progress'); + + // Opening navigates away from this page, so the panel unmounts and its poll + // stops with it. + expect(api.listBulkUnenrollBatches.mock.calls.length).toBe(callsBefore); + }); + + it('is not rendered on the upload page at all', async () => { + // The whole point of the split: uploading is a short task, the history is a + // growing archive, and the archive was pushing the file picker down. + api.listBulkUnenrollBatches.mockResolvedValue({ count: 1, results: [batchRow()] }); + + renderAt('/course_bulk_unenroll'); + + expect(await screen.findByTestId('bulk-unenroll-file-input')).toBeInTheDocument(); + expect(screen.queryByTestId('bulk-unenroll-batch-list')).not.toBeInTheDocument(); + // ...and it is not fetched either, so an idle upload page issues no polling. + expect(api.listBulkUnenrollBatches).not.toHaveBeenCalled(); + }); + + it('is reachable from the upload page', async () => { + api.listBulkUnenrollBatches.mockResolvedValue({ count: 1, results: [batchRow()] }); + + renderAt('/course_bulk_unenroll'); + await screen.findByTestId('bulk-unenroll-file-input'); + + await userEvent.click(screen.getByTestId('bulk-unenroll-view-history')); + + expect(await screen.findByTestId('bulk-unenroll-batch-list')).toBeInTheDocument(); + }); +}); diff --git a/src/CourseBulkUnenroll/CourseBulkUnenrollBatchesPage.jsx b/src/CourseBulkUnenroll/CourseBulkUnenrollBatchesPage.jsx new file mode 100644 index 000000000..91c7499cf --- /dev/null +++ b/src/CourseBulkUnenroll/CourseBulkUnenrollBatchesPage.jsx @@ -0,0 +1,56 @@ +import { useNavigate } from 'react-router-dom'; +import { Button } from '@openedx/paragon'; +import { ArrowBack } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import AlertList from '../userMessages/AlertList'; +import BatchListPanel from './BatchListPanel'; +import messages from './messages'; +import { ERROR_TOPIC } from './data/api'; +import { BATCH_ID_PARAM } from './constants'; +import ROUTES from '../data/constants/routes'; + +const { SUPPORT_TOOLS_TABS } = ROUTES; + +/** + * Run history, on its own page. + * + * Split out of the upload page: the list is a growing archive, while uploading is + * a single short task, and putting the two together pushed the file picker below + * a table that only gets longer. + * + * Opening a batch hands off to the upload page's progress view, which owns the + * polling, cancel/retry actions and the per-course table. This page is the index; + * it does not duplicate any of that. + */ +export default function CourseBulkUnenrollBatchesPage() { + const intl = useIntl(); + const navigate = useNavigate(); + + const openBatch = (batchId) => navigate( + `${SUPPORT_TOOLS_TABS.SUB_DIRECTORY.COURSE_BULK_UNENROLL}?${BATCH_ID_PARAM}=${batchId}`, + ); + + return ( +
+ + + +
+

{intl.formatMessage(messages.batchesPageTitle)}

+
+ + + + +
+ ); +} diff --git a/src/CourseBulkUnenroll/CourseBulkUnenrollIndexPage.jsx b/src/CourseBulkUnenroll/CourseBulkUnenrollIndexPage.jsx new file mode 100644 index 000000000..967092030 --- /dev/null +++ b/src/CourseBulkUnenroll/CourseBulkUnenrollIndexPage.jsx @@ -0,0 +1,313 @@ +import { + useCallback, useContext, useEffect, useRef, useState, +} from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { Button, Form, Spinner } from '@openedx/paragon'; +import { ArrowBack } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import AlertList from '../userMessages/AlertList'; +import UserMessagesContext from '../userMessages/UserMessagesContext'; +import ConfirmModal from './ConfirmModal'; +import PreviewPanel from './PreviewPanel'; +import ProgressPanel from './ProgressPanel'; +import UploadPanel from './UploadPanel'; +import messages from './messages'; +import usePolledBatchStatus from './data/hooks'; +import { + cancelBulkUnenrollBatch, confirmBulkUnenrollBatch, retryBulkUnenrollBatch, uploadBulkUnenrollFile, + ERROR_TOPIC, +} from './data/api'; +import { ALL_FILTER_VALUE, BATCH_ID_PARAM, CONFIRM_COMPLETE_DELAY_MS } from './constants'; +import ROUTES from '../data/constants/routes'; + +const { SUPPORT_TOOLS_TABS } = ROUTES; + +export default function CourseBulkUnenrollIndexPage() { + const intl = useIntl(); + const { add, clear } = useContext(UserMessagesContext); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + + // The batch id lives in the URL so a run survives a reload and can be handed + // to a colleague. A batch can take hours; the tab is not the only handle. + const batchId = searchParams.get(BATCH_ID_PARAM); + + const [preview, setPreview] = useState(null); + const [reason, setReason] = useState(''); + const [reasonTouched, setReasonTouched] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const [submitState, setSubmitState] = useState('default'); + const [isBusy, setIsBusy] = useState(false); + const [isMutating, setIsMutating] = useState(false); + const [courseFilter, setCourseFilter] = useState(ALL_FILTER_VALUE); + const [coursePage, setCoursePage] = useState(1); + const [lookupValue, setLookupValue] = useState(''); + const confirmButtonRef = useRef(null); + + const { + batch, error: statusError, isLoading, refresh, + } = usePolledBatchStatus(batchId, { + // An empty filter means "all": send no param rather than an empty one. + state: courseFilter || undefined, + page: coursePage, + }); + + // Narrowing the rows can leave the current page past the end of the new + // result set, which would show an empty table over a non-empty batch. + const handleCourseFilterChange = (value) => { + setCourseFilter(value); + setCoursePage(1); + }; + + // UserMessagesProvider rebuilds `add`/`clear` on every render, so depending on + // their identity would make `showApiError` unstable — the error effect below + // would then fire on every render, add a message, trigger another render, and + // loop forever. Hold them in a ref and keep the callback stable. + const messagesApi = useRef({ add, clear }); + messagesApi.current = { add, clear }; + + const showApiError = useCallback((result) => { + messagesApi.current.clear(ERROR_TOPIC); + result.error.forEach((entry) => messagesApi.current.add(entry)); + }, []); + + useEffect(() => { + if (statusError?.isApiError) { showApiError(statusError); } + }, [statusError, showApiError]); + + const handleUpload = async (file) => { + const result = await uploadBulkUnenrollFile(file, intl); + if (result?.isApiError) { + showApiError(result); + return; + } + clear(ERROR_TOPIC); + setPreview(result); + }; + + // The success beat below is on a timer; clear it if the page goes away first. + const completeTimerRef = useRef(null); + useEffect(() => () => { + if (completeTimerRef.current) { clearTimeout(completeTimerRef.current); } + }, []); + + const handleConfirm = async () => { + setSubmitState('pending'); + const result = await confirmBulkUnenrollBatch(preview.batch_id, reason.trim(), intl); + if (result?.isApiError) { + setSubmitState('default'); + showApiError(result); + return; + } + clear(ERROR_TOPIC); + // Hold on `complete` briefly so the operator sees the action land before the + // page changes under them. Matches CourseTeamManagement's save flow. + setSubmitState('complete'); + completeTimerRef.current = setTimeout(() => { + setSubmitState('default'); + setIsModalOpen(false); + setPreview(null); + // Hand off to the progress view by putting the batch in the URL. + setSearchParams({ [BATCH_ID_PARAM]: preview.batch_id }); + }, CONFIRM_COMPLETE_DELAY_MS); + }; + + const runMutation = async (fn) => { + setIsMutating(true); + const result = await fn(batchId, intl); + setIsMutating(false); + if (result?.isApiError) { + showApiError(result); + return; + } + clear(ERROR_TOPIC); + // Cancel and retry change state immediately; don't wait for the next poll. + refresh(); + }; + + const handleStartOver = () => { + setPreview(null); + setReason(''); + setReasonTouched(false); + setCourseFilter(ALL_FILTER_VALUE); + setCoursePage(1); + clear(ERROR_TOPIC); + setSearchParams({}); + }; + + // Opening a batch is the same transition however it was chosen — from the list + // or typed into the lookup box. Clearing the error topic matters: without it a + // "no batch found" alert from a previous attempt outlives the next one. + const openBatch = (id) => { + setPreview(null); + setCourseFilter(ALL_FILTER_VALUE); + setCoursePage(1); + messagesApi.current.clear(ERROR_TOPIC); + setSearchParams({ [BATCH_ID_PARAM]: id }); + }; + + const handleLookup = (event) => { + event.preventDefault(); + const trimmed = lookupValue.trim(); + if (!trimmed) { return; } + openBatch(trimmed); + setLookupValue(''); + }; + + const reasonIsBlank = reason.trim().length === 0; + + return ( +
+ + + +
+

{intl.formatMessage(messages.pageTitle)}

+
+

+ {intl.formatMessage(messages.pageDescription)} +

+ + {/* Opening a batch by URL or lookup fetches before it can render anything. + Without this the page is blank in the meantime — every branch below is + false while `batch` is still null. */} + {batchId && !batch && !statusError && ( +
+ + {intl.formatMessage(messages.progressHeading)} +
+ )} + + {/* Watching an existing batch takes over the page entirely. */} + {batchId && batch && ( + <> + {/* Same affordance, wording and placement as the history page: watching + a batch is a detour, and the way out is a back link at the top + rather than something the operator has to scroll past the table to + find. It drops the batch from the URL, which is what returns the + page to the file picker. */} + + runMutation(cancelBulkUnenrollBatch)} + onRetry={() => runMutation(retryBulkUnenrollBatch)} + isMutating={isMutating} + /> + + )} + + {!batchId && !preview && ( + <> + + + {/* The run history lives on its own page: it only grows, and keeping it + here pushed the file picker below an ever-longer table. */} + + +
+ +
+
+ {intl.formatMessage(messages.lookupHeading)} +
+ + + {intl.formatMessage(messages.lookupLabel)} + + setLookupValue(event.target.value)} + data-testid="bulk-unenroll-lookup-input" + /> + + +
+ + )} + + {!batchId && preview && ( + <> + + + + + {intl.formatMessage(messages.reasonLabel)} + + setReason(event.target.value)} + onBlur={() => setReasonTouched(true)} + data-testid="bulk-unenroll-reason-input" + /> + {intl.formatMessage(messages.reasonHelpText)} + {reasonTouched && reasonIsBlank && ( + + {intl.formatMessage(messages.reasonRequired)} + + )} + + +
+ + +
+ + setIsModalOpen(false)} + courseCount={(preview.courses ?? []).length} + learnerCount={preview.totals?.active ?? 0} + submitState={submitState} + positionRef={confirmButtonRef} + /> + + )} +
+ ); +} diff --git a/src/CourseBulkUnenroll/CourseBulkUnenrollIndexPage.test.jsx b/src/CourseBulkUnenroll/CourseBulkUnenrollIndexPage.test.jsx new file mode 100644 index 000000000..8c05a0887 --- /dev/null +++ b/src/CourseBulkUnenroll/CourseBulkUnenrollIndexPage.test.jsx @@ -0,0 +1,635 @@ +import '@testing-library/jest-dom'; +import { + act, render, screen, waitFor, within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +import CourseBulkUnenrollIndexPage from './CourseBulkUnenrollIndexPage'; +import UserMessagesProvider from '../userMessages/UserMessagesProvider'; +import * as api from './data/api'; + +jest.mock('./data/api', () => ({ + ...jest.requireActual('./data/api'), + uploadBulkUnenrollFile: jest.fn(), + confirmBulkUnenrollBatch: jest.fn(), + getBulkUnenrollBatchStatus: jest.fn(), + cancelBulkUnenrollBatch: jest.fn(), + retryBulkUnenrollBatch: jest.fn(), + listBulkUnenrollBatches: jest.fn(), +})); + +const BATCH_ID = 'a1b2c3d4-0000-0000-0000-000000000000'; + +const renderPage = (initialEntry = '/course_bulk_unenroll') => render( + + + + + } /> + + + + , +); + +const csvFile = () => new File(['course-v1:edX+A+B'], 'courses.csv', { type: 'text/csv' }); + +const previewResponse = { + batch_id: BATCH_ID, + state: 'validated', + total_courses: 2, + totals: { active: 4200 }, + courses: [ + { + course_id: 'course-v1:edX+A+B', active_count: 4000, state: 'pending', error: '', + }, + { + course_id: 'course-v1:edX+C+D', active_count: 200, state: 'pending', error: '', + }, + ], + errors: [], +}; + +const statusResponse = (overrides = {}) => ({ + batch_id: BATCH_ID, + state: 'running', + reason: 'cleanup', + requester: 'ops-admin', + csv_filename: 'courses.csv', + created: '2026-07-28T10:00:00Z', + modified: '2026-07-28T12:30:00Z', + total_courses: 2, + totals: { + active: 4200, unenrolled: 1000, already_inactive: 5, failed: 2, courses_finished: 1, + }, + courses: { + count: 2, + results: [ + { + course_id: 'course-v1:edX+A+B', state: 'succeeded', active_count: 4000, unenrolled: 1000, failed_count: 0, error: '', + }, + { + course_id: 'course-v1:edX+C+D', state: 'failed', active_count: 200, unenrolled: 0, failed_count: 2, error: 'boom', + }, + ], + }, + ...overrides, +}); + +beforeEach(() => { + jest.clearAllMocks(); + // The landing page always asks what is in flight; default to nothing so these + // tests exercise the upload/preview/progress paths rather than the list. + api.listBulkUnenrollBatches.mockResolvedValue({ count: 0, results: [] }); +}); + +// Unconditional, so a failing fake-timer test cannot leave timers frozen for +// every test after it — userEvent waits on a real setTimeout internally and +// would hang forever rather than fail. +afterEach(() => { + jest.useRealTimers(); +}); + +describe('CourseBulkUnenrollIndexPage — upload and preview', () => { + it('uploads the chosen file and shows the preview', async () => { + api.uploadBulkUnenrollFile.mockResolvedValue(previewResponse); + renderPage(); + + await userEvent.upload(screen.getByTestId('bulk-unenroll-file-input'), csvFile()); + await userEvent.click(screen.getByTestId('bulk-unenroll-upload-button')); + + await waitFor(() => expect(screen.getByTestId('bulk-unenroll-preview')).toBeInTheDocument()); + expect(api.uploadBulkUnenrollFile).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('bulk-unenroll-preview-summary')).toHaveTextContent('4,200'); + expect(screen.getByText('course-v1:edX+A+B')).toBeInTheDocument(); + }); + + it('re-enables the form when the upload throws instead of returning an error', async () => { + // The API layer turns request failures into a returned error object, so a + // rejection here is an unexpected fault. It must still not strand the + // operator: without clearing the busy flag the button stays disabled and + // spinning, and the only way on is a page reload. + api.uploadBulkUnenrollFile.mockRejectedValue(new Error('boom')); + renderPage(); + + await userEvent.upload(screen.getByTestId('bulk-unenroll-file-input'), csvFile()); + await userEvent.click(screen.getByTestId('bulk-unenroll-upload-button')); + + expect(await screen.findByTestId('bulk-unenroll-file-error')).toBeInTheDocument(); + const button = screen.getByTestId('bulk-unenroll-upload-button'); + expect(button).not.toBeDisabled(); + + // And a retry actually goes through. + api.uploadBulkUnenrollFile.mockResolvedValue(previewResponse); + await userEvent.click(button); + await waitFor(() => expect(screen.getByTestId('bulk-unenroll-preview')).toBeInTheDocument()); + }); + + it('refuses to upload when no file was chosen', async () => { + renderPage(); + + await userEvent.click(screen.getByTestId('bulk-unenroll-upload-button')); + + expect(await screen.findByTestId('bulk-unenroll-file-error')).toBeInTheDocument(); + expect(api.uploadBulkUnenrollFile).not.toHaveBeenCalled(); + }); + + it('marks the file input invalid and points it at the reason', async () => { + // The control is a Paragon Form.Control, so most of this wiring is the + // FormGroup's. Assert it end to end anyway: the message rendering is not the + // same thing as a screen reader being told the field is invalid and why. + renderPage(); + const input = screen.getByTestId('bulk-unenroll-file-input'); + expect(input).not.toHaveAttribute('aria-invalid'); + // react-bootstrap styles type="file" as .form-control-file, not .form-control. + expect(input).toHaveClass('form-control-file'); + // Form.Text does NOT register itself as a descriptor, so the help text is + // associated by hand. If that is ever dropped, the limits stop being + // announced and only this assertion would notice. + expect(input.getAttribute('aria-describedby')).toContain('bulk-unenroll-file-help'); + + await userEvent.click(screen.getByTestId('bulk-unenroll-upload-button')); + + const error = await screen.findByTestId('bulk-unenroll-file-error'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + // Paragon's Feedback assigns its own descriptor props; the id has to survive + // that, or aria-describedby points at nothing. + expect(error.id).toBeTruthy(); + expect(input.getAttribute('aria-describedby')).toContain(error.id); + // Both descriptors at once: the group merges its generated id with ours. + expect(input.getAttribute('aria-describedby')).toContain('bulk-unenroll-file-help'); + + // Choosing a file clears the error, and the invalid state goes with it. + await userEvent.upload(input, csvFile()); + expect(input).not.toHaveAttribute('aria-invalid'); + }); + + it('lists rejected rows with their CSV line numbers', async () => { + api.uploadBulkUnenrollFile.mockResolvedValue({ + ...previewResponse, + errors: [ + { row: 3, value: 'not-a-course', error: 'Not a valid course id' }, + { row: 7, value: 'a,b', error: 'Expected exactly one column' }, + ], + }); + renderPage(); + + await userEvent.upload(screen.getByTestId('bulk-unenroll-file-input'), csvFile()); + await userEvent.click(screen.getByTestId('bulk-unenroll-upload-button')); + + const rejected = await screen.findByTestId('bulk-unenroll-rejected-rows'); + expect(within(rejected).getByText(/Row 3/)).toBeInTheDocument(); + expect(within(rejected).getByText(/Not a valid course id/)).toBeInTheDocument(); + expect(within(rejected).getByText(/Row 7/)).toBeInTheDocument(); + }); + + it('shows an alert when the upload is rejected', async () => { + api.uploadBulkUnenrollFile.mockResolvedValue({ + isApiError: true, + error: [{ + code: null, dismissible: true, text: 'That file is too large.', type: 'danger', topic: 'courseBulkUnenrollApiErrors', + }], + }); + renderPage(); + + await userEvent.upload(screen.getByTestId('bulk-unenroll-file-input'), csvFile()); + await userEvent.click(screen.getByTestId('bulk-unenroll-upload-button')); + + expect(await screen.findByText('That file is too large.')).toBeInTheDocument(); + expect(screen.queryByTestId('bulk-unenroll-preview')).not.toBeInTheDocument(); + }); +}); + +describe('CourseBulkUnenrollIndexPage — preview table controls', () => { + const showPreview = async (response = previewResponse) => { + api.uploadBulkUnenrollFile.mockResolvedValue(response); + renderPage(); + await userEvent.upload(screen.getByTestId('bulk-unenroll-file-input'), csvFile()); + await userEvent.click(screen.getByTestId('bulk-unenroll-upload-button')); + await screen.findByTestId('bulk-unenroll-preview'); + }; + + it('narrows the rows to a searched course id', async () => { + // Client-side: the whole course array arrives in the upload response, which + // is why this table can search at all where the paginated ones cannot. + await showPreview(); + + await userEvent.type(screen.getByTestId('bulk-unenroll-preview-search'), 'C+D'); + + await waitFor(() => expect(screen.queryByText('course-v1:edX+A+B')).not.toBeInTheDocument()); + expect(screen.getByText('course-v1:edX+C+D')).toBeInTheDocument(); + }); + + it('reports the filtered count, not the whole file', async () => { + await showPreview(); + expect(screen.getByTestId('bulk-unenroll-table-count')).toHaveTextContent('2'); + + await userEvent.type(screen.getByTestId('bulk-unenroll-preview-search'), 'C+D'); + + await waitFor(() => expect(screen.getByTestId('bulk-unenroll-table-count')).toHaveTextContent('1')); + }); + + it('isolates course ids the server could not find', async () => { + // The trap this exists for: a CSV where nothing resolves previews as + // "0 learners across N courses", which reads as "nothing to do" rather than + // "none of these exist". + await showPreview({ + ...previewResponse, + totals: { active: 0 }, + courses: [ + { + course_id: 'course-v1:edX+A+B', active_count: 0, state: 'pending', error: 'Course not found', + }, + { + course_id: 'course-v1:edX+C+D', active_count: 200, state: 'pending', error: '', + }, + ], + }); + + await userEvent.click(screen.getByTestId('bulk-unenroll-preview-filter')); + await userEvent.click(screen.getByTestId('bulk-unenroll-preview-filter-item-not_found')); + + await waitFor(() => expect(screen.queryByText('course-v1:edX+C+D')).not.toBeInTheDocument()); + expect(screen.getByText('course-v1:edX+A+B')).toBeInTheDocument(); + expect(screen.getByTestId('bulk-unenroll-table-count')).toHaveTextContent('1'); + }); + + it('keeps the rows in the order the CSV listed them', async () => { + // Not sortable: the operator will fix the file in the order they wrote it, + // so reordering the preview would cost them the line they are looking for. + await showPreview(); + + const ids = screen.getAllByText(/^course-v1:edX/).map((node) => node.textContent); + expect(ids).toEqual(['course-v1:edX+A+B', 'course-v1:edX+C+D']); + expect(screen.queryByTestId('sort-icon-active_count')).not.toBeInTheDocument(); + }); +}); + +describe('CourseBulkUnenrollIndexPage — reason and confirm', () => { + const getToPreview = async () => { + api.uploadBulkUnenrollFile.mockResolvedValue(previewResponse); + renderPage(); + await userEvent.upload(screen.getByTestId('bulk-unenroll-file-input'), csvFile()); + await userEvent.click(screen.getByTestId('bulk-unenroll-upload-button')); + await screen.findByTestId('bulk-unenroll-preview'); + }; + + it('keeps the confirm button disabled until a reason is entered', async () => { + await getToPreview(); + + expect(screen.getByTestId('bulk-unenroll-open-confirm')).toBeDisabled(); + + await userEvent.type(screen.getByTestId('bulk-unenroll-reason-input'), 'partner offboarding'); + + expect(screen.getByTestId('bulk-unenroll-open-confirm')).not.toBeDisabled(); + }); + + it('treats a whitespace-only reason as blank', async () => { + await getToPreview(); + + await userEvent.type(screen.getByTestId('bulk-unenroll-reason-input'), ' '); + + expect(screen.getByTestId('bulk-unenroll-open-confirm')).toBeDisabled(); + }); + + it('shows the learner count in the modal and confirms with the trimmed reason', async () => { + api.confirmBulkUnenrollBatch.mockResolvedValue({ batch_id: BATCH_ID, state: 'pending' }); + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse()); + await getToPreview(); + + await userEvent.type(screen.getByTestId('bulk-unenroll-reason-input'), ' cleanup '); + await userEvent.click(screen.getByTestId('bulk-unenroll-open-confirm')); + + const modal = await screen.findByTestId('bulk-unenroll-confirm-modal'); + expect(within(modal).getByTestId('bulk-unenroll-confirm-count')).toHaveTextContent('4,200'); + + await userEvent.click(screen.getByTestId('bulk-unenroll-confirm-submit')); + + await waitFor(() => expect(api.confirmBulkUnenrollBatch).toHaveBeenCalledWith(BATCH_ID, 'cleanup', expect.anything())); + }); + + it('confirms the action, then hands off to the progress view', async () => { + // The button rests on `complete` for a beat before the page changes, so the + // operator sees an irreversible action land rather than the screen just + // swapping under them. Matches CourseTeamManagement's save flow. + api.confirmBulkUnenrollBatch.mockResolvedValue({ batch_id: BATCH_ID, state: 'pending' }); + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse()); + await getToPreview(); + + await userEvent.type(screen.getByTestId('bulk-unenroll-reason-input'), 'cleanup'); + await userEvent.click(screen.getByTestId('bulk-unenroll-open-confirm')); + await screen.findByTestId('bulk-unenroll-confirm-modal'); + await userEvent.click(screen.getByTestId('bulk-unenroll-confirm-submit')); + + expect(await screen.findByText('Started')).toBeInTheDocument(); + expect(await screen.findByTestId('bulk-unenroll-progress', {}, { timeout: 4000 })).toBeInTheDocument(); + }); + + it('keeps the operator on the preview when confirm fails', async () => { + // No hand-off on failure: the batch is still `validated` and confirmable. + api.confirmBulkUnenrollBatch.mockResolvedValue({ + isApiError: true, + error: [{ + code: null, dismissible: true, text: 'The batch could not be confirmed.', type: 'danger', topic: 'courseBulkUnenrollApiErrors', + }], + }); + await getToPreview(); + + await userEvent.type(screen.getByTestId('bulk-unenroll-reason-input'), 'cleanup'); + await userEvent.click(screen.getByTestId('bulk-unenroll-open-confirm')); + await screen.findByTestId('bulk-unenroll-confirm-modal'); + await userEvent.click(screen.getByTestId('bulk-unenroll-confirm-submit')); + + expect(await screen.findByText('The batch could not be confirmed.')).toBeInTheDocument(); + expect(screen.getByTestId('bulk-unenroll-preview')).toBeInTheDocument(); + expect(screen.queryByTestId('bulk-unenroll-progress')).not.toBeInTheDocument(); + }); + + it('does not confirm when the modal is dismissed', async () => { + await getToPreview(); + + await userEvent.type(screen.getByTestId('bulk-unenroll-reason-input'), 'cleanup'); + await userEvent.click(screen.getByTestId('bulk-unenroll-open-confirm')); + await screen.findByTestId('bulk-unenroll-confirm-modal'); + await userEvent.click(screen.getByTestId('bulk-unenroll-confirm-cancel')); + + expect(api.confirmBulkUnenrollBatch).not.toHaveBeenCalled(); + }); +}); + +describe('CourseBulkUnenrollIndexPage — progress view', () => { + it('opens straight into progress when the URL carries a batch id', async () => { + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse()); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + + expect(await screen.findByTestId('bulk-unenroll-progress')).toBeInTheDocument(); + // The file picker is not offered while watching a batch. + expect(screen.queryByTestId('bulk-unenroll-file-input')).not.toBeInTheDocument(); + expect(api.getBulkUnenrollBatchStatus) + .toHaveBeenCalledWith(BATCH_ID, { state: undefined, page: 1 }, expect.anything()); + }); + + it('measures the progress bar in learners, not courses', async () => { + // Courses-finished would read 1/2 = 50% here. Learners read + // (1000 unenrolled + 5 already_inactive + 2 failed) / 4200 active = 24%. + // Course granularity leaves a single-course batch pinned at 0% for its whole + // run, which is exactly when one course can hold millions of learners. + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse()); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + await screen.findByTestId('bulk-unenroll-progress'); + + expect(screen.getByText('24%')).toBeInTheDocument(); + expect(screen.queryByText('50%')).not.toBeInTheDocument(); + }); + + it('keeps the bar at 100% ceiling when late enrolments exceed the preview count', async () => { + // `active` is counted at upload; more learners can enrol before the worker + // runs, so processed can exceed it. The bar must not read 130%. + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse({ + state: 'succeeded', + totals: { + active: 100, unenrolled: 130, already_inactive: 0, failed: 0, courses_finished: 2, + }, + })); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + await screen.findByTestId('bulk-unenroll-progress'); + + expect(screen.getByText('100%')).toBeInTheDocument(); + }); + + it('shows batch-level totals rather than a sum of the visible page', async () => { + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse()); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + + expect(await screen.findByTestId('bulk-unenroll-progress-learners')).toHaveTextContent('1,000'); + expect(screen.getByTestId('bulk-unenroll-progress-courses')).toHaveTextContent('1 of 2'); + expect(screen.getByTestId('bulk-unenroll-progress-failures')).toHaveTextContent('2'); + expect(screen.getByTestId('bulk-unenroll-progress-inactive')).toHaveTextContent('5'); + }); + + it('shows the reason the batch was run', async () => { + // The reason is the audit trail for an irreversible action; it was collected + // at confirm time and, before this, never shown back. + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse({ reason: 'partner offboarding' })); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + + const details = await screen.findByTestId('bulk-unenroll-batch-details'); + expect(within(details).getByText('partner offboarding')).toBeInTheDocument(); + expect(within(details).getByText('ops-admin')).toBeInTheDocument(); + expect(within(details).getByText('courses.csv')).toBeInTheDocument(); + expect(within(details).getByText(BATCH_ID)).toBeInTheDocument(); + }); + + it('says so when a batch carries no reason', async () => { + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse({ reason: '' })); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + + const details = await screen.findByTestId('bulk-unenroll-batch-details'); + expect(within(details).getByText('Not recorded')).toBeInTheDocument(); + }); + + it('labels the last timestamp "Finished" only once the batch has stopped', async () => { + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse({ state: 'succeeded' })); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + + const details = await screen.findByTestId('bulk-unenroll-batch-details'); + expect(within(details).getByText('Finished')).toBeInTheDocument(); + expect(within(details).queryByText('Last updated')).not.toBeInTheDocument(); + }); + + it('calls the last timestamp "Last updated" while the batch is still running', async () => { + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse({ state: 'running' })); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + + const details = await screen.findByTestId('bulk-unenroll-batch-details'); + expect(within(details).getByText('Last updated')).toBeInTheDocument(); + expect(within(details).queryByText('Finished')).not.toBeInTheDocument(); + }); + + // Polling tests live in CourseBulkUnenrollPolling.test.jsx — they need fake + // timers, which interfere with the tests in this file. + + it('offers cancel while running and calls it', async () => { + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse()); + api.cancelBulkUnenrollBatch.mockResolvedValue({ state: 'cancelled' }); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + await screen.findByTestId('bulk-unenroll-progress'); + + await userEvent.click(screen.getByTestId('bulk-unenroll-cancel-button')); + + await waitFor(() => expect(api.cancelBulkUnenrollBatch).toHaveBeenCalledWith(BATCH_ID, expect.anything())); + }); + + it('shows cancelled courses as cancelled and finished ones as they ended', async () => { + // A cancelled batch used to leave its unfinished courses reading "running" + // forever, so a stopped batch looked like it was still working. + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse({ + state: 'cancelled', + totals: { + active: 4200, unenrolled: 1000, already_inactive: 0, failed: 0, courses_finished: 2, + }, + courses: { + count: 2, + results: [ + { + course_id: 'course-v1:edX+A+B', state: 'succeeded', active_count: 4000, unenrolled: 1000, failed_count: 0, error: '', + }, + { + course_id: 'course-v1:edX+C+D', state: 'cancelled', active_count: 200, unenrolled: 0, failed_count: 0, error: '', + }, + ], + }, + })); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + await screen.findByTestId('bulk-unenroll-progress'); + + expect(screen.getByText('Succeeded')).toBeInTheDocument(); + // Both the batch badge and the stopped course row read "Cancelled". + expect(screen.getAllByText('Cancelled').length).toBeGreaterThanOrEqual(2); + expect(screen.queryByText('Running')).not.toBeInTheDocument(); + // A cancelled course will not be worked on again, so progress reads complete. + expect(screen.getByTestId('bulk-unenroll-progress-courses')).toHaveTextContent('2'); + }); + + it('offers retry only for a finished-but-imperfect batch', async () => { + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse({ state: 'partial' })); + api.retryBulkUnenrollBatch.mockResolvedValue({ state: 'pending' }); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + await screen.findByTestId('bulk-unenroll-progress'); + + // A partial batch has stopped, so cancel is meaningless here. + expect(screen.queryByTestId('bulk-unenroll-cancel-button')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('bulk-unenroll-retry-button')); + + await waitFor(() => expect(api.retryBulkUnenrollBatch).toHaveBeenCalledWith(BATCH_ID, expect.anything())); + }); + + it('refetches with a failures filter when that choice is picked', async () => { + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse()); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + await screen.findByTestId('bulk-unenroll-progress'); + + await userEvent.click(screen.getByTestId('bulk-unenroll-course-filter')); + await userEvent.click(screen.getByTestId('bulk-unenroll-course-filter-item-failed')); + + await waitFor(() => expect(api.getBulkUnenrollBatchStatus) + .toHaveBeenCalledWith(BATCH_ID, { state: 'failed', page: 1 }, expect.anything())); + }); + + it('offers every course status the table can display', async () => { + // Includes `cancelled`, which the cancel sweep now writes — it was added to + // the model but initially left out of this menu, so the courses a cancelled + // batch stopped could not be filtered to. + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse()); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + await screen.findByTestId('bulk-unenroll-progress'); + await userEvent.click(screen.getByTestId('bulk-unenroll-course-filter')); + + ['pending', 'running', 'succeeded', 'failed', 'skipped', 'cancelled'].forEach( + (state) => expect(screen.getByTestId(`bulk-unenroll-course-filter-item-${state}`)).toBeInTheDocument(), + ); + }); + + it('filters the course table to cancelled courses', async () => { + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse()); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + await screen.findByTestId('bulk-unenroll-progress'); + + await userEvent.click(screen.getByTestId('bulk-unenroll-course-filter')); + await userEvent.click(screen.getByTestId('bulk-unenroll-course-filter-item-cancelled')); + + await waitFor(() => expect(api.getBulkUnenrollBatchStatus) + .toHaveBeenCalledWith(BATCH_ID, { state: 'cancelled', page: 1 }, expect.anything())); + }); + + it('fetches the next page of courses from the server', async () => { + // The course list is server-paginated. Without fetchData wired through, the + // footer's page buttons moved a local index and the rows never changed. + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse({ + courses: { count: 140, results: [{ course_id: 'course-v1:X+A+1', state: 'succeeded' }] }, + })); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + await screen.findByTestId('bulk-unenroll-progress'); + + await userEvent.click(screen.getByLabelText(/next/i)); + + await waitFor(() => expect(api.getBulkUnenrollBatchStatus) + .toHaveBeenCalledWith(BATCH_ID, { state: undefined, page: 2 }, expect.anything())); + }); + + it('resets to the first page when the filter changes', async () => { + // Narrowing the rows can otherwise strand the viewer on a page past the end + // of the new result set — an empty table over a non-empty batch. + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse({ + courses: { count: 140, results: [{ course_id: 'course-v1:X+A+1', state: 'succeeded' }] }, + })); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + await screen.findByTestId('bulk-unenroll-progress'); + + await userEvent.click(screen.getByLabelText(/next/i)); + await waitFor(() => expect(api.getBulkUnenrollBatchStatus) + .toHaveBeenCalledWith(BATCH_ID, { state: undefined, page: 2 }, expect.anything())); + + await userEvent.click(screen.getByTestId('bulk-unenroll-course-filter')); + await userEvent.click(screen.getByTestId('bulk-unenroll-course-filter-item-failed')); + + await waitFor(() => expect(api.getBulkUnenrollBatchStatus) + .toHaveBeenCalledWith(BATCH_ID, { state: 'failed', page: 1 }, expect.anything())); + }); +}); + +describe('CourseBulkUnenrollIndexPage — batch lookup', () => { + it('opens a batch entered by id', async () => { + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse()); + renderPage(); + + await userEvent.type(screen.getByTestId('bulk-unenroll-lookup-input'), BATCH_ID); + await userEvent.click(screen.getByTestId('bulk-unenroll-lookup-button')); + + expect(await screen.findByTestId('bulk-unenroll-progress')).toBeInTheDocument(); + }); + + it('shows a loading indicator instead of a blank page while the batch loads', async () => { + let resolveStatus; + api.getBulkUnenrollBatchStatus.mockReturnValue( + new Promise((resolve) => { resolveStatus = resolve; }), + ); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + + expect(await screen.findByTestId('bulk-unenroll-loading')).toBeInTheDocument(); + + await act(async () => { resolveStatus(statusResponse()); }); + + expect(await screen.findByTestId('bulk-unenroll-progress')).toBeInTheDocument(); + expect(screen.queryByTestId('bulk-unenroll-loading')).not.toBeInTheDocument(); + }); + + it('ignores a blank lookup', async () => { + renderPage(); + + await userEvent.click(screen.getByTestId('bulk-unenroll-lookup-button')); + + expect(api.getBulkUnenrollBatchStatus).not.toHaveBeenCalled(); + expect(screen.queryByTestId('bulk-unenroll-progress')).not.toBeInTheDocument(); + }); +}); diff --git a/src/CourseBulkUnenroll/CourseBulkUnenrollPolling.test.jsx b/src/CourseBulkUnenroll/CourseBulkUnenrollPolling.test.jsx new file mode 100644 index 000000000..292e1f86d --- /dev/null +++ b/src/CourseBulkUnenroll/CourseBulkUnenrollPolling.test.jsx @@ -0,0 +1,135 @@ +/** + * Polling tests, deliberately kept in their own file. + * + * These are the only tests that use fake timers. Running them alongside the + * rest left React's scheduler in a state where a later test's effect never + * fired — the symptom was a component that mounted but never fetched. Jest + * gives each test file its own module registry and environment, so splitting + * them is the reliable isolation boundary rather than per-test cleanup. + */ +import '@testing-library/jest-dom'; +import { + act, render, screen, waitFor, +} from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +import CourseBulkUnenrollIndexPage from './CourseBulkUnenrollIndexPage'; +import UserMessagesProvider from '../userMessages/UserMessagesProvider'; +import * as api from './data/api'; +import { POLL_INTERVAL_MS } from './constants'; + +jest.mock('./data/api', () => ({ + ...jest.requireActual('./data/api'), + uploadBulkUnenrollFile: jest.fn(), + confirmBulkUnenrollBatch: jest.fn(), + getBulkUnenrollBatchStatus: jest.fn(), + cancelBulkUnenrollBatch: jest.fn(), + retryBulkUnenrollBatch: jest.fn(), + listBulkUnenrollBatches: jest.fn(), +})); + +const BATCH_ID = 'a1b2c3d4-0000-0000-0000-000000000000'; + +const renderPage = (initialEntry) => render( + + + + + } /> + + + + , +); + +const statusResponse = (overrides = {}) => ({ + batch_id: BATCH_ID, + state: 'running', + reason: 'cleanup', + total_courses: 2, + totals: { + active: 4200, unenrolled: 1000, already_inactive: 5, failed: 2, courses_finished: 1, + }, + courses: { + count: 1, + results: [ + { + course_id: 'course-v1:edX+A+B', state: 'succeeded', active_count: 4000, unenrolled: 1000, failed_count: 0, error: '', + }, + ], + }, + ...overrides, +}); + +beforeEach(() => { + jest.clearAllMocks(); + api.listBulkUnenrollBatches.mockResolvedValue({ count: 0, results: [] }); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('CourseBulkUnenrollIndexPage — polling', () => { + it('keeps polling while the batch is running', async () => { + jest.useFakeTimers(); + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse()); + + const { unmount } = renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + + await waitFor(() => expect(api.getBulkUnenrollBatchStatus).toHaveBeenCalledTimes(1)); + + await act(async () => { + jest.advanceTimersByTime(POLL_INTERVAL_MS); + }); + + await waitFor(() => expect(api.getBulkUnenrollBatchStatus).toHaveBeenCalledTimes(2)); + + // The batch stays 'running', so the loop is still live; tear it down while + // timers are still fake. + unmount(); + }); + + it('stops polling once the batch reaches a terminal state', async () => { + jest.useFakeTimers(); + api.getBulkUnenrollBatchStatus.mockResolvedValue(statusResponse({ state: 'succeeded' })); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + + await waitFor(() => expect(api.getBulkUnenrollBatchStatus).toHaveBeenCalledTimes(1)); + + await act(async () => { + jest.advanceTimersByTime(POLL_INTERVAL_MS * 4); + }); + + // Still one call: a finished batch will never change again, and a tab left + // open overnight must not keep hitting the API. + expect(api.getBulkUnenrollBatchStatus).toHaveBeenCalledTimes(1); + }); + + it('stops polling when the batch cannot be loaded', async () => { + jest.useFakeTimers(); + api.getBulkUnenrollBatchStatus.mockResolvedValue({ + isApiError: true, + error: [{ + code: null, dismissible: true, text: 'No batch found with that ID.', type: 'danger', topic: 'courseBulkUnenrollApiErrors', + }], + }); + + renderPage(`/course_bulk_unenroll?batch_id=${BATCH_ID}`); + + await waitFor(() => expect(api.getBulkUnenrollBatchStatus).toHaveBeenCalledTimes(1)); + + await act(async () => { + jest.advanceTimersByTime(POLL_INTERVAL_MS * 3); + }); + + // A 404 or a permission failure will not fix itself; retrying every 5s + // would just repeat it. + expect(api.getBulkUnenrollBatchStatus).toHaveBeenCalledTimes(1); + + jest.useRealTimers(); + expect(await screen.findByText('No batch found with that ID.')).toBeInTheDocument(); + }); +}); diff --git a/src/CourseBulkUnenroll/PreviewPanel.jsx b/src/CourseBulkUnenroll/PreviewPanel.jsx new file mode 100644 index 000000000..2349635b4 --- /dev/null +++ b/src/CourseBulkUnenroll/PreviewPanel.jsx @@ -0,0 +1,171 @@ +import { Suspense, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import { DataTable, Form, Icon } from '@openedx/paragon'; +import { Search } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import Alert from '../userMessages/Alert'; +import StateFilterDropdown from './StateFilterDropdown'; +import TableActions from './TableActions'; +import messages from './messages'; +import { + ALL_FILTER_VALUE, PREVIEW_FILTER, PREVIEW_FILTER_OPTIONS, TABLE_PAGE_SIZE, +} from './constants'; + +/** + * Step 2: the dry-run preview. + * + * Rejected rows come first and carry their 1-based CSV line number, so the + * operator can fix the spreadsheet without hunting. + * + * The table follows CourseTeamManagement's control-bar pattern — a search box and + * a status filter above a paged client-side slice. Searching and paging work here + * because the whole course array arrives in the upload response; the two + * server-paginated tables can do neither. + * + * **No column sorting**, deliberately. The rows are the operator's CSV in the + * order they wrote it, which is the order they will fix it in; the two questions + * they actually have — "is this id in the file?" and "which ones did not resolve?" + * — are answered by the search box and the filter. + * + * The Found / Not found filter also answers a real trap: a CSV where every id is + * unknown previews as "0 learners across N courses", which reads as "nothing to + * do" rather than "none of these exist". + */ +export default function PreviewPanel({ courses, errors, totalLearners }) { + const intl = useIntl(); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState(ALL_FILTER_VALUE); + const [tableState, setTableState] = useState({ pageIndex: 0, pageSize: TABLE_PAGE_SIZE }); + + const filteredData = useMemo(() => { + const term = search.trim().toLowerCase(); + return courses.filter((row) => { + const matchesSearch = !term || (row.course_id || '').toLowerCase().includes(term); + if (filter === PREVIEW_FILTER.VALID) { return matchesSearch && !row.error; } + if (filter === PREVIEW_FILTER.NOT_FOUND) { return matchesSearch && !!row.error; } + return matchesSearch; + }); + }, [courses, search, filter]); + + const currentPageData = useMemo(() => { + const start = tableState.pageIndex * tableState.pageSize; + return filteredData.slice(start, start + tableState.pageSize); + }, [filteredData, tableState.pageIndex, tableState.pageSize]); + + // Narrowing the rows can strand the viewer on a page past the end of the new + // result set, which would show an empty table over a non-empty preview. + const resetToFirstPage = () => setTableState((prev) => ({ ...prev, pageIndex: 0 })); + + const tableActions = ( + + { setSearch(event.target.value); resetToFirstPage(); }} + trailingElement={} + data-testid="bulk-unenroll-preview-search" + /> + { setFilter(value); resetToFirstPage(); }} + options={PREVIEW_FILTER_OPTIONS} + /> + + ); + + return ( +
+
+ {intl.formatMessage(messages.previewHeading)} +
+
+

+ {intl.formatMessage(messages.previewSummary, { + learners: totalLearners.toLocaleString(), + courses: courses.length.toLocaleString(), + })} +

+
+ + {errors.length > 0 && ( + + +
+ + {intl.formatMessage(messages.rejectedRowsHeading, { count: errors.length })} + +
    + {errors.map((rowError) => ( +
  • + {intl.formatMessage(messages.rejectedRow, { + row: rowError.row, + message: rowError.error, + })} +
  • + ))} +
+
+
+
+ )} + +
+ + + {filteredData.length > 0 && } + {filteredData.length === 0 && ( +
{intl.formatMessage(messages.noCourses)}
+ )} + {filteredData.length > 0 && } +
+
+
+ ); +} + +PreviewPanel.propTypes = { + courses: PropTypes.arrayOf(PropTypes.shape({ + course_id: PropTypes.string, + active_count: PropTypes.number, + error: PropTypes.string, + })).isRequired, + errors: PropTypes.arrayOf(PropTypes.shape({ + row: PropTypes.number, + value: PropTypes.string, + error: PropTypes.string, + })).isRequired, + totalLearners: PropTypes.number.isRequired, +}; diff --git a/src/CourseBulkUnenroll/ProgressPanel.jsx b/src/CourseBulkUnenroll/ProgressPanel.jsx new file mode 100644 index 000000000..79f46e146 --- /dev/null +++ b/src/CourseBulkUnenroll/ProgressPanel.jsx @@ -0,0 +1,283 @@ +import { + useCallback, useEffect, useRef, useState, +} from 'react'; +import PropTypes from 'prop-types'; +import { + Alert, Badge, Button, DataTable, ProgressBar, Spinner, Stack, +} from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; +import BatchMetadata from './BatchMetadata'; +import StateFilterDropdown from './StateFilterDropdown'; +import TableActions from './TableActions'; +import { stateLabel } from './utils'; +import { + BATCH_STATE, + BATCH_STATE_VARIANTS, + CANCELLABLE_BATCH_STATES, + COURSE_STATE_FILTER_OPTIONS, + COURSE_STATE_VARIANTS, + RETRYABLE_BATCH_STATES, + TABLE_PAGE_SIZE, +} from './constants'; + +// Module scope, not inline: defined inside the parent this would be a new +// component type on every poll, and React would tear down the rows' DOM. +function CourseStateCell({ row }) { + const intl = useIntl(); + const { state } = row.original; + return ( + + {stateLabel(intl, state)} + + ); +} +CourseStateCell.propTypes = { + row: PropTypes.shape({ + original: PropTypes.shape({ state: PropTypes.string }).isRequired, + }).isRequired, +}; + +/** + * Step 4: watch a running batch. + * + * The numbers here come from the batch-level `totals` rather than from the + * visible rows: the table shows one page at a time, so summing what is on + * screen would understate a 2000-course batch. + * + * The course table is paginated *by the server*, so it cannot sort or search — + * it gets the control bar and a status filter only. Page changes are handed back + * up so the polling hook can refetch; without that wiring the footer's page + * buttons move a local index and the rows never change. + */ +export default function ProgressPanel({ + batch, isLoading, courseFilter, onCourseFilterChange, page, onPageChange, + onCancel, onRetry, isMutating, +}) { + const intl = useIntl(); + const [tableState, setTableState] = useState({ pageIndex: page - 1, pageSize: TABLE_PAGE_SIZE }); + + // DataTable owns the page index; the fetch is driven from `page` one level up. + // Keep them in step in both directions so a filter change that resets the page + // is reflected in the footer. + useEffect(() => { + setTableState((prev) => (prev.pageIndex === page - 1 ? prev : { ...prev, pageIndex: page - 1 })); + }, [page]); + + // `page` is read through a ref so this callback can stay stable. Paragon's + // fetchData effect lists the callback in its dependencies, so an identity that + // changes each render re-fires it forever — the loop CourseTeamManagement + // avoids by handing it a plain useState setter. + const pageRef = useRef(page); + pageRef.current = page; + + const handleFetchData = useCallback((next) => { + setTableState((prev) => ( + prev.pageIndex === next.pageIndex && prev.pageSize === next.pageSize + ? prev + : { ...prev, pageIndex: next.pageIndex, pageSize: next.pageSize } + )); + if (next.pageIndex !== pageRef.current - 1) { onPageChange(next.pageIndex + 1); } + }, [onPageChange]); + + const totals = batch.totals ?? {}; + const coursesFinished = totals.courses_finished ?? 0; + const totalCourses = batch.total_courses ?? 0; + + // Measure the bar in LEARNERS, not courses. A course only counts as finished + // once every one of its chunks lands, so a single-course batch would sit at + // 0% for the whole run and snap to 100% at the end — and one course can hold + // millions of learners, which is exactly when progress matters most. + // Learner counts advance every chunk, so the bar moves continuously. + const processed = (totals.unenrolled ?? 0) + + (totals.already_inactive ?? 0) + + (totals.failed ?? 0); + const totalLearners = totals.active ?? 0; + // Clamp: `active` is the preview count taken at upload, so late enrolments can + // push the worker's real total past it. + const percent = totalLearners > 0 + ? Math.min(100, Math.round((processed / totalLearners) * 100)) + : 0; + + // `courses` is paginated here (unlike the upload response, where it is a + // plain array), so the rows live under `results`. + const rows = batch.courses?.results ?? []; + const rowCount = batch.courses?.count ?? rows.length; + + const canCancel = CANCELLABLE_BATCH_STATES.includes(batch.state); + const canRetry = RETRYABLE_BATCH_STATES.includes(batch.state); + + const tableActions = ( + + + + ); + + return ( +
+ +
+ {intl.formatMessage(messages.progressHeading)} +
+ + {stateLabel(intl, batch.state)} + + {isLoading && } +
+ + + + + + {intl.formatMessage(messages.progressCourses, { + finished: coursesFinished.toLocaleString(), + total: totalCourses.toLocaleString(), + })} + + + {intl.formatMessage(messages.progressLearners, { + unenrolled: (totals.unenrolled ?? 0).toLocaleString(), + })} + + + {intl.formatMessage(messages.progressAlreadyInactive, { + count: (totals.already_inactive ?? 0).toLocaleString(), + })} + + + {intl.formatMessage(messages.progressFailures, { + count: (totals.failed ?? 0).toLocaleString(), + })} + + + + + + {batch.state === BATCH_STATE.CANCELLED && ( + + {intl.formatMessage(messages.cancelledNotice)} + + )} + + {(canCancel || canRetry) && ( +
+ {canCancel && ( + + )} + {canRetry && ( + + )} +
+ )} + +
+ + + {rows.length > 0 && } + {rows.length === 0 && ( +
{intl.formatMessage(messages.noCourses)}
+ )} + {rowCount > 0 && } +
+
+
+ ); +} + +ProgressPanel.propTypes = { + batch: PropTypes.shape({ + batch_id: PropTypes.string, + state: PropTypes.string, + reason: PropTypes.string, + requester: PropTypes.string, + csv_filename: PropTypes.string, + created: PropTypes.string, + modified: PropTypes.string, + total_courses: PropTypes.number, + totals: PropTypes.shape({ + courses_finished: PropTypes.number, + unenrolled: PropTypes.number, + already_inactive: PropTypes.number, + failed: PropTypes.number, + }), + courses: PropTypes.shape({ + count: PropTypes.number, + results: PropTypes.arrayOf(PropTypes.shape({ + course_id: PropTypes.string, + state: PropTypes.string, + })), + }), + }).isRequired, + isLoading: PropTypes.bool.isRequired, + courseFilter: PropTypes.string.isRequired, + onCourseFilterChange: PropTypes.func.isRequired, + page: PropTypes.number.isRequired, + onPageChange: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + onRetry: PropTypes.func.isRequired, + isMutating: PropTypes.bool.isRequired, +}; diff --git a/src/CourseBulkUnenroll/StateFilterDropdown.jsx b/src/CourseBulkUnenroll/StateFilterDropdown.jsx new file mode 100644 index 000000000..42e69cf9b --- /dev/null +++ b/src/CourseBulkUnenroll/StateFilterDropdown.jsx @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import { Dropdown, Icon } from '@openedx/paragon'; +import { ArrowDropDown, Check } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +/** + * The status filter used by all three bulk-unenroll tables. + * + * Mirrors CourseTeamManagement's status dropdown: an `outline-primary` toggle + * showing the active choice with a trailing caret, and a menu that marks the + * active item with a check. + * + * Composed from `Dropdown` + `Dropdown.Toggle` rather than `DropdownButton` + * (which CTM's `TableActions` uses) for one reason: `DropdownButton` forwards + * stray props to its wrapper `div`, so a `data-testid` never reaches the button + * and cannot be clicked. CTM's own `formatRole` uses this same composition. + * + * Options are `{value, label}` pairs, matching CTM's + * `COURSE_STATUS_DROPDOWN_OPTIONS`. A value may name several API states at once + * (`"pending,running"`) — the filter answers an operator's question, which does + * not always map to exactly one row of the data model. + */ +export default function StateFilterDropdown({ + id, value, onChange, options, testId, +}) { + const intl = useIntl(); + const active = options.find((option) => option.value === value) ?? options[0]; + + return ( + + + + {intl.formatMessage(active.label)} + + + + + + {options.map((option) => ( + + {intl.formatMessage(option.label)} + {value === option.value && } + + ))} + + + ); +} + +StateFilterDropdown.propTypes = { + id: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + options: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.string.isRequired, + // A react-intl message descriptor. + label: PropTypes.shape({ id: PropTypes.string.isRequired }).isRequired, + })).isRequired, + testId: PropTypes.string.isRequired, +}; diff --git a/src/CourseBulkUnenroll/TableActions.jsx b/src/CourseBulkUnenroll/TableActions.jsx new file mode 100644 index 000000000..fc4bc8a91 --- /dev/null +++ b/src/CourseBulkUnenroll/TableActions.jsx @@ -0,0 +1,53 @@ +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +/** + * The control bar above each table: filters on the left, row count on the right. + * + * Reproduces CourseTeamManagement's `TableActions` layout and reuses its + * `custom-table-*` classes, which are already global (every feature stylesheet is + * imported by `src/index.scss`). Paragon's own left-hand actions and status text + * are hidden by `.course-bulk-unenroll-table` so this element owns the whole bar. + * + * The count is passed in rather than derived: two of the three tables are + * server-paginated, so only the caller knows the real total. + */ +export default function TableActions({ + children, pageIndex, pageSize, totalItems, +}) { + const intl = useIntl(); + + const startItemIndex = totalItems === 0 ? 0 : (pageIndex * pageSize) + 1; + const endItemIndex = Math.min(startItemIndex + pageSize - 1, totalItems); + + return ( +
+
+ {children} +
+ +
+

+ {intl.formatMessage(messages.tableNoOfEntriesShowingLabel, { + startItemIndex, + endItemIndex, + totalFilteredItems: totalItems, + })} +

+
+
+ ); +} + +TableActions.propTypes = { + children: PropTypes.node, + pageIndex: PropTypes.number.isRequired, + pageSize: PropTypes.number.isRequired, + totalItems: PropTypes.number.isRequired, +}; + +TableActions.defaultProps = { + children: null, +}; diff --git a/src/CourseBulkUnenroll/UploadPanel.jsx b/src/CourseBulkUnenroll/UploadPanel.jsx new file mode 100644 index 000000000..22e5e9fa4 --- /dev/null +++ b/src/CourseBulkUnenroll/UploadPanel.jsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Button, Form, Spinner } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; +import { MAX_FILE_BYTES, MAX_ROWS } from './constants'; + +// Form.Control.Feedback registers itself with Paragon's descriptor list, but +// Form.Text does not — so the help text keeps an explicit id and is handed to +// the control, which merges it with the feedback's generated one. +const HELP_TEXT_ID = 'bulk-unenroll-file-help'; + +/** + * Step 1: pick a CSV and upload it. + * + * A plain file input rather than Paragon's Dropzone: this repo has no existing + * drag-and-drop pattern to copy, and the native control is keyboard- and + * screen-reader-accessible for free. + */ +export default function UploadPanel({ onUploaded, isBusy, setIsBusy }) { + const intl = useIntl(); + const [file, setFile] = useState(null); + const [localError, setLocalError] = useState(null); + + const handleSubmit = async (event) => { + event.preventDefault(); + if (!file) { + setLocalError(intl.formatMessage(messages.noFileSelected)); + return; + } + setLocalError(null); + setIsBusy(true); + try { + await onUploaded(file); + } catch { + // `onUploaded` is a prop, so this component cannot assume it resolves. The + // API layer turns request failures into a returned error object rather than + // a rejection, which means anything landing here is an unexpected fault — + // but leaving the button spinning would strand the operator with no way + // forward but a reload, so say so and let them try again. + setLocalError(intl.formatMessage(messages.uploadError)); + } finally { + // Whatever happened, the form has to be usable again. + setIsBusy(false); + } + }; + + return ( +
+ {/* controlId gives the group the input's id, which Form.Label picks up as + htmlFor and Form.Control.Feedback extends with its own descriptor id. + react-bootstrap renders type="file" as .form-control-file and merges + .is-invalid from the group's isInvalid. aria-invalid stays explicit: + no Paragon form control emits it. */} + + + {intl.formatMessage(messages.fileLabel)} + + { + setFile(event.target.files?.[0] ?? null); + setLocalError(null); + }} + /> + + {intl.formatMessage(messages.fileHelpText, { + maxRows: MAX_ROWS, + maxMegabytes: Math.round(MAX_FILE_BYTES / (1024 * 1024)), + })} + + {localError && ( + + {localError} + + )} + + +
+ ); +} + +UploadPanel.propTypes = { + onUploaded: PropTypes.func.isRequired, + isBusy: PropTypes.bool.isRequired, + setIsBusy: PropTypes.func.isRequired, +}; diff --git a/src/CourseBulkUnenroll/constants.js b/src/CourseBulkUnenroll/constants.js new file mode 100644 index 000000000..8cdf19a34 --- /dev/null +++ b/src/CourseBulkUnenroll/constants.js @@ -0,0 +1,145 @@ +import messages from './messages'; + +// Batch-level states, mirroring BulkUnenrollBatch.State on the LMS side. +export const BATCH_STATE = { + VALIDATED: 'validated', + PENDING: 'pending', + RUNNING: 'running', + SUCCEEDED: 'succeeded', + PARTIAL: 'partial', + FAILED: 'failed', + CANCELLED: 'cancelled', +}; + +// Per-course states, mirroring BulkUnenrollCourseState.State. +export const COURSE_STATE = { + PENDING: 'pending', + RUNNING: 'running', + SUCCEEDED: 'succeeded', + FAILED: 'failed', + SKIPPED: 'skipped', + // Set on courses that had not finished when the batch was cancelled. Distinct + // from SKIPPED: some of this course's learners may already be unenrolled. + CANCELLED: 'cancelled', +}; + +// A batch in one of these states will never change again, so polling stops. +export const TERMINAL_BATCH_STATES = [ + BATCH_STATE.SUCCEEDED, + BATCH_STATE.PARTIAL, + BATCH_STATE.FAILED, + BATCH_STATE.CANCELLED, +]; + +// Only these can be cancelled; anything else has already stopped. +export const CANCELLABLE_BATCH_STATES = [ + BATCH_STATE.PENDING, + BATCH_STATE.RUNNING, +]; + +// Retry re-runs the failed courses of a finished-but-imperfect batch. +export const RETRYABLE_BATCH_STATES = [ + BATCH_STATE.PARTIAL, + BATCH_STATE.FAILED, +]; + +// Badge colour per batch state, shared by the batch list and the progress view +// so one state never reads as two different things on the two screens. +export const BATCH_STATE_VARIANTS = { + [BATCH_STATE.SUCCEEDED]: 'success', + [BATCH_STATE.PARTIAL]: 'warning', + [BATCH_STATE.FAILED]: 'danger', + [BATCH_STATE.CANCELLED]: 'light', + [BATCH_STATE.RUNNING]: 'info', + [BATCH_STATE.PENDING]: 'light', + [BATCH_STATE.VALIDATED]: 'light', +}; + +// Badge colour per course state. Kept beside BATCH_STATE_VARIANTS rather than +// derived from it: the two vocabularies only partly overlap (a course can be +// `skipped`, a batch can be `partial`), so one map cannot serve both. +export const COURSE_STATE_VARIANTS = { + [COURSE_STATE.SUCCEEDED]: 'success', + [COURSE_STATE.FAILED]: 'danger', + [COURSE_STATE.RUNNING]: 'info', + [COURSE_STATE.PENDING]: 'light', + [COURSE_STATE.SKIPPED]: 'light', + // Same neutral treatment the batch badge gives a cancelled batch, so the two + // levels of the same screen do not disagree about what cancelled looks like. + [COURSE_STATE.CANCELLED]: 'light', +}; + +// Table filter options, shaped like CourseTeamManagement's +// COURSE_STATUS_DROPDOWN_OPTIONS: a list of {value, label} pairs. +// +// One option per real state, deliberately. Grouped shortcuts ("in flight", +// "needs attention") were tried first and removed: they left the states an +// operator could actually see in the Status column — `validated` and `cancelled` +// dominate real data — with no way to select them, and offered groups that +// matched nothing. If it can appear in the column, it can be filtered on. +// +// `value: ''` is the unfiltered choice; the API call omits the param entirely +// rather than sending an empty one. +export const ALL_FILTER_VALUE = ''; + +export const BATCH_STATE_FILTER_OPTIONS = [ + { value: ALL_FILTER_VALUE, label: messages.filterAll }, + { value: BATCH_STATE.VALIDATED, label: messages.stateValidated }, + { value: BATCH_STATE.PENDING, label: messages.statePending }, + { value: BATCH_STATE.RUNNING, label: messages.stateRunning }, + { value: BATCH_STATE.SUCCEEDED, label: messages.stateSucceeded }, + { value: BATCH_STATE.PARTIAL, label: messages.statePartial }, + { value: BATCH_STATE.FAILED, label: messages.stateFailed }, + { value: BATCH_STATE.CANCELLED, label: messages.stateCancelled }, +]; + +export const COURSE_STATE_FILTER_OPTIONS = [ + { value: ALL_FILTER_VALUE, label: messages.filterAll }, + { value: COURSE_STATE.PENDING, label: messages.statePending }, + { value: COURSE_STATE.RUNNING, label: messages.stateRunning }, + { value: COURSE_STATE.SUCCEEDED, label: messages.stateSucceeded }, + { value: COURSE_STATE.FAILED, label: messages.stateFailed }, + { value: COURSE_STATE.SKIPPED, label: messages.stateSkipped }, + { value: COURSE_STATE.CANCELLED, label: messages.stateCancelled }, +]; + +// Preview rows are filtered on whether the server flagged the course, not on a +// state field — a previewed course has no state yet. Filtering happens in the +// browser here; the whole array arrives in the upload response. +export const PREVIEW_FILTER = { + ALL: ALL_FILTER_VALUE, + VALID: 'valid', + NOT_FOUND: 'not_found', +}; + +export const PREVIEW_FILTER_OPTIONS = [ + { value: PREVIEW_FILTER.ALL, label: messages.filterAll }, + { value: PREVIEW_FILTER.VALID, label: messages.filterValid }, + { value: PREVIEW_FILTER.NOT_FOUND, label: messages.filterNotFound }, +]; + +// Rows per page, sent to the server as `page_size` *and* used for the table's +// page-count maths. The two must agree: the server's own default is 10, so a +// client that assumed a different size computed `pageCount` from the wrong +// denominator and silently disabled its own paging controls. +export const TABLE_PAGE_SIZE = 10; + +// How long the confirm button rests on `complete` before the page hands off to +// the progress view. Same 1s beat CourseTeamManagement uses after a save. +export const CONFIRM_COMPLETE_DELAY_MS = 1000; + +export const POLL_INTERVAL_MS = 5000; + +// The batch list is polled more slowly than a single batch: it is background +// awareness ("is anything running?"), not progress being watched, and unlike one +// batch it has no terminal state to stop on. +export const BATCH_LIST_POLL_INTERVAL_MS = 15000; + +// Mirrors BULK_UNENROLL_MAX_FILE_BYTES / BULK_UNENROLL_MAX_ROWS in lms/envs/common.py. +// Duplicated here only to phrase the server's rejection in the operator's terms — +// the server remains the authority and rejects oversized files regardless. +export const MAX_FILE_BYTES = 5 * 1024 * 1024; +export const MAX_ROWS = 2000; + +// URL param that makes a run shareable and reload-proof. +export const BATCH_ID_PARAM = 'batch_id'; diff --git a/src/CourseBulkUnenroll/data/api.js b/src/CourseBulkUnenroll/data/api.js new file mode 100644 index 000000000..ea9474e5c --- /dev/null +++ b/src/CourseBulkUnenroll/data/api.js @@ -0,0 +1,173 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform'; + +import messages from '../messages'; +import { MAX_FILE_BYTES, MAX_ROWS, TABLE_PAGE_SIZE } from '../constants'; + +export const ERROR_TOPIC = 'courseBulkUnenrollApiErrors'; + +const baseUrl = () => `${getConfig().LMS_BASE_URL}/api/support/v1/bulk_unenroll`; + +/** + * Wrap a message in the alert shape the shared UserMessages alert list expects. + */ +function toAlert(intl, message, values) { + return { + error: [ + { + code: null, + dismissible: true, + text: intl.formatMessage(message, values), + type: 'danger', + topic: ERROR_TOPIC, + }, + ], + isApiError: true, + }; +} + +/** + * Turn an axios failure into an operator-readable alert. + * + * The server is the authority on the size and row limits, so we translate its + * rejections rather than pre-empting them: a 413 is always "too large", and a + * 400 mentioning rows is the row cap. Anything else falls back to `fallback`. + */ +function toError(intl, error, fallback) { + const status = error?.response?.status; + const data = error?.response?.data; + + if (status === 403 || status === 401) { + return toAlert(intl, messages.permissionError); + } + if (status === 404) { + return toAlert(intl, messages.batchNotFoundError); + } + if (status === 413) { + return toAlert(intl, messages.fileTooLargeError, { + maxMegabytes: Math.round(MAX_FILE_BYTES / (1024 * 1024)), + }); + } + if (status === 400) { + // DRF ValidationError bodies are {field: [msg]} or {field: msg}; flatten to + // one string so we can tell the row-cap rejection from everything else. + const detail = JSON.stringify(data ?? ''); + if (/row/i.test(detail)) { + return toAlert(intl, messages.tooManyRowsError, { maxRows: MAX_ROWS }); + } + const fieldMessage = data && typeof data === 'object' + ? Object.values(data).flat().filter(v => typeof v === 'string')[0] + : null; + if (fieldMessage) { + return { + error: [{ + code: null, dismissible: true, text: fieldMessage, type: 'danger', topic: ERROR_TOPIC, + }], + isApiError: true, + }; + } + } + return toAlert(intl, fallback); +} + +/** + * Upload a CSV of course ids. The response is the dry-run preview *and* creates + * the batch — there is no separate preview call, and nothing is mutated yet. + * + * Note: we never set Content-Type. The browser must set it so the multipart + * boundary is included. + */ +export async function uploadBulkUnenrollFile(file, intl) { + const formData = new FormData(); + formData.append('file', file); + try { + const { data } = await getAuthenticatedHttpClient().post(`${baseUrl()}/`, formData); + return data; + } catch (error) { + return toError(intl, error, messages.uploadError); + } +} + +/** + * List batches, newest first, optionally narrowed by state. + * + * `state` is comma-separated (e.g. `pending,running`) so one request covers every + * in-flight state. Without this the batch id — surfaced only in the URL after + * confirming — is the sole handle on a run that may last hours. + */ +export async function listBulkUnenrollBatches(options, intl) { + const { page, state } = options || {}; + const params = new URLSearchParams(); + if (page) { params.append('page', page); } + if (state) { params.append('state', state); } + // The server's DefaultPagination defaults to 10 per page. Ask for the size the + // table actually renders, or the two disagree and the table's page count comes + // out wrong — which silently disables its own paging controls. + params.append('page_size', TABLE_PAGE_SIZE); + const query = params.toString(); + try { + const { data } = await getAuthenticatedHttpClient().get( + `${baseUrl()}/${query ? `?${query}` : ''}`, + ); + return data; + } catch (error) { + return toError(intl, error, messages.listError); + } +} + +/** + * Confirm a validated batch and start the work. The reason is collected here, + * not at upload, so the operator sees the preview before justifying the action. + */ +export async function confirmBulkUnenrollBatch(batchId, reason, intl) { + try { + const { data } = await getAuthenticatedHttpClient().post( + `${baseUrl()}/${batchId}/confirm/`, + { reason }, + ); + return data; + } catch (error) { + return toError(intl, error, messages.confirmError); + } +} + +/** + * Fetch a batch's summary and one page of its per-course rows. + * `state` filters the listed rows only; the totals stay batch-wide. + */ +export async function getBulkUnenrollBatchStatus(batchId, options, intl) { + const { page, state } = options || {}; + const params = new URLSearchParams(); + if (page) { params.append('page', page); } + if (state) { params.append('state', state); } + // See listBulkUnenrollBatches: keep the server's page size and the table's in + // step, or the paging controls go dead. + params.append('page_size', TABLE_PAGE_SIZE); + const query = params.toString(); + try { + const { data } = await getAuthenticatedHttpClient().get( + `${baseUrl()}/${batchId}/${query ? `?${query}` : ''}`, + ); + return data; + } catch (error) { + return toError(intl, error, messages.statusError); + } +} + +export async function cancelBulkUnenrollBatch(batchId, intl) { + try { + const { data } = await getAuthenticatedHttpClient().post(`${baseUrl()}/${batchId}/cancel/`); + return data; + } catch (error) { + return toError(intl, error, messages.cancelError); + } +} + +export async function retryBulkUnenrollBatch(batchId, intl) { + try { + const { data } = await getAuthenticatedHttpClient().post(`${baseUrl()}/${batchId}/retry/`); + return data; + } catch (error) { + return toError(intl, error, messages.retryError); + } +} diff --git a/src/CourseBulkUnenroll/data/api.test.js b/src/CourseBulkUnenroll/data/api.test.js new file mode 100644 index 000000000..72dd1ecd8 --- /dev/null +++ b/src/CourseBulkUnenroll/data/api.test.js @@ -0,0 +1,202 @@ +import MockAdapter from 'axios-mock-adapter'; +import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { createIntl } from '@edx/frontend-platform/i18n'; + +import { + cancelBulkUnenrollBatch, + confirmBulkUnenrollBatch, + getBulkUnenrollBatchStatus, + listBulkUnenrollBatches, + retryBulkUnenrollBatch, + uploadBulkUnenrollFile, +} from './api'; + +const intl = createIntl({ locale: 'en', messages: {} }); + +describe('Bulk Unenroll API', () => { + let mockAdapter; + const { LMS_BASE_URL } = getConfig(); + const base = `${LMS_BASE_URL}/api/support/v1/bulk_unenroll`; + const batchId = 'a1b2c3d4-0000-0000-0000-000000000000'; + + beforeEach(() => { + mockAdapter = new MockAdapter(getAuthenticatedHttpClient(), { onNoMatch: 'throwException' }); + }); + + afterEach(() => { + mockAdapter.reset(); + }); + + describe('uploadBulkUnenrollFile', () => { + it('posts the file to the collection URL and returns the preview', async () => { + const preview = { batch_id: batchId, courses: [], errors: [] }; + mockAdapter.onPost(`${base}/`).reply(200, preview); + + const file = new File(['course-v1:edX+A+B'], 'courses.csv', { type: 'text/csv' }); + const result = await uploadBulkUnenrollFile(file, intl); + + expect(result).toEqual(preview); + expect(mockAdapter.history.post).toHaveLength(1); + expect(mockAdapter.history.post[0].url).toEqual(`${base}/`); + }); + + it('does not set Content-Type, so the browser can add the multipart boundary', async () => { + mockAdapter.onPost(`${base}/`).reply(200, {}); + const file = new File(['x'], 'courses.csv', { type: 'text/csv' }); + + await uploadBulkUnenrollFile(file, intl); + + const sentHeaders = mockAdapter.history.post[0].headers; + // A hardcoded multipart Content-Type would omit the boundary and the + // server would fail to parse the upload. + expect(sentHeaders['Content-Type']).not.toMatch(/multipart\/form-data;\s*boundary/); + expect(sentHeaders['Content-Type']).not.toEqual('multipart/form-data'); + }); + + it('reports a 413 as a file-size problem', async () => { + mockAdapter.onPost(`${base}/`).reply(413); + const file = new File(['x'], 'courses.csv', { type: 'text/csv' }); + + const result = await uploadBulkUnenrollFile(file, intl); + + expect(result.isApiError).toBe(true); + expect(result.error[0].text).toMatch(/too large/i); + }); + + it('reports a row-count rejection distinctly from other 400s', async () => { + mockAdapter.onPost(`${base}/`).reply(400, { file: ['Too many rows: 2500 exceeds 2000.'] }); + const file = new File(['x'], 'courses.csv', { type: 'text/csv' }); + + const result = await uploadBulkUnenrollFile(file, intl); + + expect(result.error[0].text).toMatch(/too many rows/i); + }); + + it('surfaces a non-row 400 message from the server verbatim', async () => { + mockAdapter.onPost(`${base}/`).reply(400, { file: ['A CSV file is required.'] }); + const file = new File(['x'], 'courses.csv', { type: 'text/csv' }); + + const result = await uploadBulkUnenrollFile(file, intl); + + expect(result.error[0].text).toEqual('A CSV file is required.'); + }); + + it('reports a 403 as a permission problem', async () => { + mockAdapter.onPost(`${base}/`).reply(403); + const file = new File(['x'], 'courses.csv', { type: 'text/csv' }); + + const result = await uploadBulkUnenrollFile(file, intl); + + expect(result.error[0].text).toMatch(/permission/i); + }); + }); + + describe('confirmBulkUnenrollBatch', () => { + it('posts the reason to the confirm URL', async () => { + mockAdapter.onPost(`${base}/${batchId}/confirm/`).reply(202, { batch_id: batchId, state: 'pending' }); + + const result = await confirmBulkUnenrollBatch(batchId, 'partner offboarding', intl); + + expect(result.state).toEqual('pending'); + expect(mockAdapter.history.post).toHaveLength(1); + expect(JSON.parse(mockAdapter.history.post[0].data)).toEqual({ reason: 'partner offboarding' }); + }); + + it('surfaces a blank-reason rejection from the server', async () => { + mockAdapter.onPost(`${base}/${batchId}/confirm/`).reply(400, { + reason: 'A non-blank reason is required to confirm.', + }); + + const result = await confirmBulkUnenrollBatch(batchId, '', intl); + + expect(result.error[0].text).toEqual('A non-blank reason is required to confirm.'); + }); + }); + + describe('getBulkUnenrollBatchStatus', () => { + it('asks for the page size the table renders', async () => { + // The server's default is 10. Without this the table computes its page + // count from 100 and its own paging controls go dead. + mockAdapter.onGet(/.*/).reply(200, { batch_id: batchId }); + + await getBulkUnenrollBatchStatus(batchId, {}, intl); + + expect(mockAdapter.history.get[0].url).toEqual(`${base}/${batchId}/?page_size=10`); + }); + + it('passes the state filter through as a query param', async () => { + mockAdapter.onGet(/.*/).reply(200, { batch_id: batchId }); + + await getBulkUnenrollBatchStatus(batchId, { state: 'failed' }, intl); + + expect(mockAdapter.history.get[0].url).toEqual(`${base}/${batchId}/?state=failed&page_size=10`); + }); + + it('reports an unknown batch as not found', async () => { + mockAdapter.onGet(/.*/).reply(404); + + const result = await getBulkUnenrollBatchStatus(batchId, {}, intl); + + expect(result.error[0].text).toMatch(/no batch found/i); + }); + }); + + describe('listBulkUnenrollBatches', () => { + it('gets the collection URL and returns the paginated payload', async () => { + const payload = { count: 1, results: [{ batch_id: batchId, state: 'running' }] }; + mockAdapter.onGet(/.*/).reply(200, payload); + + const result = await listBulkUnenrollBatches({}, intl); + + expect(result).toEqual(payload); + expect(mockAdapter.history.get[0].url).toEqual(`${base}/?page_size=10`); + }); + + it('sends several states as one comma-separated filter', async () => { + // One request for every in-flight state, rather than one per state. + mockAdapter.onGet(/.*/).reply(200, { count: 0, results: [] }); + + await listBulkUnenrollBatches({ state: 'pending,running' }, intl); + + expect(mockAdapter.history.get[0].url).toEqual(`${base}/?state=pending%2Crunning&page_size=10`); + }); + + it('reports a failure as an api error rather than throwing', async () => { + mockAdapter.onGet(/.*/).reply(500); + + const result = await listBulkUnenrollBatches({}, intl); + + expect(result.isApiError).toBe(true); + expect(result.error[0].text).toMatch(/list of batches/i); + }); + }); + + describe('cancel and retry', () => { + it('posts to the cancel URL', async () => { + mockAdapter.onPost(`${base}/${batchId}/cancel/`).reply(200, { state: 'cancelled' }); + + const result = await cancelBulkUnenrollBatch(batchId, intl); + + expect(result.state).toEqual('cancelled'); + expect(mockAdapter.history.post[0].url).toEqual(`${base}/${batchId}/cancel/`); + }); + + it('posts to the retry URL', async () => { + mockAdapter.onPost(`${base}/${batchId}/retry/`).reply(202, { state: 'pending' }); + + const result = await retryBulkUnenrollBatch(batchId, intl); + + expect(result.state).toEqual('pending'); + expect(mockAdapter.history.post[0].url).toEqual(`${base}/${batchId}/retry/`); + }); + + it('reports a cancel failure', async () => { + mockAdapter.onPost(`${base}/${batchId}/cancel/`).reply(500); + + const result = await cancelBulkUnenrollBatch(batchId, intl); + + expect(result.isApiError).toBe(true); + }); + }); +}); diff --git a/src/CourseBulkUnenroll/data/hooks.js b/src/CourseBulkUnenroll/data/hooks.js new file mode 100644 index 000000000..ce5315e7c --- /dev/null +++ b/src/CourseBulkUnenroll/data/hooks.js @@ -0,0 +1,200 @@ +import { + useCallback, useEffect, useRef, useState, +} from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { getBulkUnenrollBatchStatus, listBulkUnenrollBatches } from './api'; +import { + BATCH_LIST_POLL_INTERVAL_MS, POLL_INTERVAL_MS, TERMINAL_BATCH_STATES, +} from '../constants'; + +/** + * Poll the batch list so the landing page shows what is currently in flight. + * + * `state` and `page` are separate primitive arguments rather than an options + * object: an object literal is a new value every render, and these feed the + * effect below, so it would tear down and restart the timer continuously. + * `state` is the comma-separated API filter. + * + * Changing either refetches immediately without disturbing the poll cadence. + * + * Returns { batches, count, error, isLoading, refresh }; `batches` is null until + * the first response, which is how the caller tells "not loaded yet" from "empty". + */ +export function usePolledBatchList(state, page = 1) { + const intl = useIntl(); + const [batches, setBatches] = useState(null); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // Held in a ref so the polling callback always reads the current filter and + // page without the timer having to be recreated whenever either changes. + const queryRef = useRef({ state, page }); + queryRef.current = { state, page }; + + // Every request takes a ticket; only the newest may write to state. A filter or + // page change fires a fetch while a poll tick is already in flight, and nothing + // guarantees they come back in order — without this, a slow reply for the + // previous page can land last and replace the results actually asked for. + const requestSeq = useRef(0); + + const fetchOnce = useCallback(async () => { + const seq = requestSeq.current + 1; + requestSeq.current = seq; + setIsLoading(true); + const data = await listBulkUnenrollBatches(queryRef.current, intl); + // Superseded: the newer request owns the display (and will clear isLoading). + // Still returned, so the poll loop can schedule off it as before. + if (seq !== requestSeq.current) { return data; } + setIsLoading(false); + if (data?.isApiError) { + setError(data); + return null; + } + setError(null); + setBatches(data?.results ?? []); + setCount(data?.count ?? 0); + return data; + }, [intl]); + + useEffect(() => { + let cancelled = false; + let timer = null; + + const tick = async () => { + const data = await fetchOnce(); + if (cancelled) { return; } + // Unlike a single batch there is no terminal state to stop on — a colleague + // can start a run at any moment. Stop only on error, which polling will not + // fix. The slower interval keeps an idle tab from hammering the API. + if (!data) { return; } + timer = setTimeout(tick, BATCH_LIST_POLL_INTERVAL_MS); + }; + + tick(); + + return () => { + cancelled = true; + if (timer) { clearTimeout(timer); } + }; + }, [fetchOnce]); + + // Refetch when the filter or page changes, without restarting the poll timer. + const firstListRender = useRef(true); + useEffect(() => { + if (firstListRender.current) { + firstListRender.current = false; + return; + } + fetchOnce(); + }, [state, page]); // eslint-disable-line react-hooks/exhaustive-deps + + return { + batches, count, error, isLoading, refresh: fetchOnce, + }; +} + +/** + * Poll a batch's status until it reaches a terminal state. + * + * A batch can run for hours, so polling has to stop on its own: once the batch + * is succeeded/partial/failed/cancelled nothing will change again, and a tab + * left open overnight must not keep hitting the API. + * + * Returns { batch, error, isLoading, refresh } — `refresh` re-fetches + * immediately (used after cancel/retry, which change state right away). + */ +export default function usePolledBatchStatus(batchId, { state, page = 1 } = {}) { + const intl = useIntl(); + const [batch, setBatch] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // Held in a ref so the interval callback always sees the current filter and + // page without having to tear down and recreate the timer on every change. + const queryRef = useRef({ state, page }); + queryRef.current = { state, page }; + + // Every request takes a ticket; only the newest may write to state. Opening B + // while A's request is in flight would otherwise let A's reply land last and + // overwrite B — the id is baked into each closure, so a late reply is + // indistinguishable from a fresh one once it resolves. + const requestSeq = useRef(0); + + // Which batch the state below actually describes. Kept in state (not a ref) so + // the reset happens during render: a batch the operator has navigated away from + // must not survive even one painted frame under the new batch's URL, because the + // cancel/retry buttons act on the URL's id, not on what is being displayed. + const [shownBatchId, setShownBatchId] = useState(batchId); + if (batchId !== shownBatchId) { + setShownBatchId(batchId); + setBatch(null); + setError(null); + // Retire the outstanding request here, not when the next one starts. The new + // batch's fetch is kicked off by a passive effect, which runs after paint, + // while a promise settles on the microtask queue — so there is a real gap in + // which the old request would still hold the current ticket and could put the + // previous batch back on screen. Navigating away is itself the invalidation. + requestSeq.current += 1; + } + + const fetchOnce = useCallback(async () => { + if (!batchId) { return null; } + const seq = requestSeq.current + 1; + requestSeq.current = seq; + setIsLoading(true); + const data = await getBulkUnenrollBatchStatus(batchId, queryRef.current, intl); + // Superseded: the newer request owns the display (and will clear isLoading). + // Still returned, so the poll loop can schedule off it as before. + if (seq !== requestSeq.current) { return data; } + setIsLoading(false); + if (data?.isApiError) { + setError(data); + return null; + } + setError(null); + setBatch(data); + return data; + }, [batchId, intl]); + + useEffect(() => { + // Clearing on the way out is the render-phase reset's job now, not this one's. + if (!batchId) { return undefined; } + + let cancelled = false; + let timer = null; + + const tick = async () => { + const data = await fetchOnce(); + if (cancelled) { return; } + // Stop on a terminal state, and also on error — a 404 or a permission + // failure will not fix itself, and retrying every 5s just repeats it. + if (!data || TERMINAL_BATCH_STATES.includes(data.state)) { return; } + timer = setTimeout(tick, POLL_INTERVAL_MS); + }; + + tick(); + + return () => { + cancelled = true; + if (timer) { clearTimeout(timer); } + }; + // `state`/`page` are intentionally excluded: changing the row filter or + // turning a page should refetch (handled below) but not restart the loop. + }, [batchId, fetchOnce]); + + // Refetch when the row filter or page changes, without disturbing the timer. + const firstRender = useRef(true); + useEffect(() => { + if (firstRender.current) { + firstRender.current = false; + return; + } + fetchOnce(); + }, [state, page]); // eslint-disable-line react-hooks/exhaustive-deps + + return { + batch, error, isLoading, refresh: fetchOnce, + }; +} diff --git a/src/CourseBulkUnenroll/data/hooks.test.jsx b/src/CourseBulkUnenroll/data/hooks.test.jsx new file mode 100644 index 000000000..211b772cc --- /dev/null +++ b/src/CourseBulkUnenroll/data/hooks.test.jsx @@ -0,0 +1,224 @@ +/** + * Out-of-order response handling for the two polling hooks. + * + * These hooks fire overlapping requests by design — a poll tick, a filter change + * and a page change can all be in flight at once — and nothing makes replies + * arrive in the order they were sent. Every test here resolves its deferred + * promises *in reverse*, which is the case that ordinary mocking never produces + * because `mockResolvedValue` always settles in call order. + * + * The hooks are driven through a probe component rather than `renderHook`: this + * repo is on @testing-library/react v12, which does not ship one. + */ +import '@testing-library/jest-dom'; +import { StrictMode } from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import PropTypes from 'prop-types'; + +import usePolledBatchStatus, { usePolledBatchList } from './hooks'; +import * as api from './api'; + +jest.mock('./api', () => ({ + ...jest.requireActual('./api'), + getBulkUnenrollBatchStatus: jest.fn(), + listBulkUnenrollBatches: jest.fn(), +})); + +const BATCH_A = 'aaaaaaaa-0000-0000-0000-000000000000'; +const BATCH_B = 'bbbbbbbb-0000-0000-0000-000000000000'; + +/** A promise whose settlement this test controls. */ +const deferred = () => { + let resolve; + const promise = new Promise((res) => { resolve = res; }); + return { promise, resolve }; +}; + +const batchResponse = (batchId) => ({ + batch_id: batchId, + state: 'running', + reason: 'cleanup', + total_courses: 1, + totals: { + active: 1, unenrolled: 0, already_inactive: 0, failed: 0, courses_finished: 0, + }, + courses: { count: 0, results: [] }, +}); + +function StatusProbe({ batchId }) { + const { batch, error } = usePolledBatchStatus(batchId); + return ( + <> + {batch ? batch.batch_id : 'none'} + {error ? 'error' : 'none'} + + ); +} + +function ListProbe({ page }) { + const { batches } = usePolledBatchList(undefined, page); + return ( + + {batches ? batches.map((b) => b.batch_id).join(',') : 'none'} + + ); +} + +StatusProbe.propTypes = { batchId: PropTypes.string }; +StatusProbe.defaultProps = { batchId: undefined }; +ListProbe.propTypes = { page: PropTypes.number.isRequired }; + +// One stable messages object for every render. A fresh literal would give +// IntlProvider a new context value each time, `useIntl()` a new identity, and the +// hooks' poll effect a reason to tear down and refetch — an extra request that has +// nothing to do with what these tests are measuring. +const MESSAGES = {}; +const wrap = (ui) => {ui}; + +const renderProbe = (ui) => render(wrap(ui)); + +const shownBatch = () => screen.getByTestId('batch').textContent; + +beforeEach(() => jest.clearAllMocks()); + +describe('usePolledBatchStatus — stale responses', () => { + it('ignores a reply for the batch the operator has navigated away from', async () => { + const first = deferred(); + const second = deferred(); + api.getBulkUnenrollBatchStatus + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + const { rerender } = renderProbe(); + rerender(wrap()); + + // B answers first, then A's request finally comes back — the reverse of the + // order they were sent, which is exactly what a slow first request looks like. + await act(async () => { second.resolve(batchResponse(BATCH_B)); }); + expect(shownBatch()).toBe(BATCH_B); + + await act(async () => { first.resolve(batchResponse(BATCH_A)); }); + expect(shownBatch()).toBe(BATCH_B); // the late reply must not win + }); + + it('clears the previous batch the moment the id changes', async () => { + const first = deferred(); + const second = deferred(); + api.getBulkUnenrollBatchStatus + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + const { rerender } = renderProbe(); + await act(async () => { first.resolve(batchResponse(BATCH_A)); }); + expect(shownBatch()).toBe(BATCH_A); + + await act(async () => { + rerender(wrap()); + }); + + // B has not answered yet. A's progress must not sit under B's id for even one + // frame: cancel and retry act on the id in the URL, not on what is displayed. + expect(shownBatch()).toBe('none'); + }); + + it('retires an in-flight request the moment the operator navigates away', async () => { + const first = deferred(); + api.getBulkUnenrollBatchStatus.mockReturnValueOnce(first.promise); + + const { rerender } = renderProbe(); + + // The back link drops the id from the URL, and with no id the hook starts no + // request — so nothing after this point will bump the sequence. If leaving a + // batch did not itself invalidate its request, A's reply would still hold the + // current ticket when it lands and would put A back. + // + // This is the honest version of the A -> B window: there, the new batch's + // fetch is started by a passive effect that runs after paint, while a promise + // settles on the microtask queue, so the same gap exists — but `rerender` + // flushes effects inside `act`, which closes it before a test can look. + await act(async () => { rerender(wrap()); }); + expect(shownBatch()).toBe('none'); + + await act(async () => { first.resolve(batchResponse(BATCH_A)); }); + expect(shownBatch()).toBe('none'); + expect(api.getBulkUnenrollBatchStatus).toHaveBeenCalledTimes(1); // no successor ran + }); + + it('does not leave the previous batch on screen when the new one 404s', async () => { + const first = deferred(); + const second = deferred(); + api.getBulkUnenrollBatchStatus + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + const { rerender } = renderProbe(); + await act(async () => { first.resolve(batchResponse(BATCH_A)); }); + + await act(async () => { + rerender(wrap()); + }); + await act(async () => { + second.resolve({ isApiError: true, error: [{ text: 'No batch found with that ID.' }] }); + }); + + // An error path never calls setBatch, so without the reset A's details would + // stay visible under B's URL indefinitely, next to a "not found" alert. + expect(shownBatch()).toBe('none'); + expect(screen.getByTestId('error')).toHaveTextContent('error'); + }); +}); + +describe('usePolledBatchList — stale responses', () => { + it('ignores a reply for a page the operator has already moved off', async () => { + const pageOne = deferred(); + const pageTwo = deferred(); + api.listBulkUnenrollBatches + .mockReturnValueOnce(pageOne.promise) + .mockReturnValueOnce(pageTwo.promise); + + const { rerender } = renderProbe(); + await act(async () => { + rerender(wrap()); + }); + + await act(async () => { pageTwo.resolve({ count: 2, results: [{ batch_id: BATCH_B }] }); }); + expect(screen.getByTestId('batches')).toHaveTextContent(BATCH_B); + + await act(async () => { pageOne.resolve({ count: 2, results: [{ batch_id: BATCH_A }] }); }); + expect(screen.getByTestId('batches')).toHaveTextContent(BATCH_B); + }); +}); + +describe('usePolledBatchStatus — under StrictMode', () => { + it('resets without React warnings when the batch id changes', async () => { + // The reset adjusts this component's own state during its own render — the + // pattern React documents for "adjusting state when a prop changes", and the + // only one that avoids a painted frame of the previous batch. StrictMode + // double-invokes render, so if that were unsupported here it would surface as + // a warning or a re-render loop. It must also stay idempotent: the second + // render sees batchId === shownBatchId and does nothing. + const warn = jest.spyOn(console, 'error').mockImplementation(() => {}); + const first = deferred(); + api.getBulkUnenrollBatchStatus.mockReturnValueOnce(first.promise); + + const strict = (ui) => {wrap(ui)}; + const { rerender } = render(strict()); + await act(async () => { first.resolve(batchResponse(BATCH_A)); }); + expect(shownBatch()).toBe(BATCH_A); + + const second = deferred(); + api.getBulkUnenrollBatchStatus.mockReturnValueOnce(second.promise); + await act(async () => { rerender(strict()); }); + + expect(shownBatch()).toBe('none'); + expect(warn).not.toHaveBeenCalled(); + + // The double-rendered reset bumps the ticket more than once; that is harmless + // because the counter is only ever compared for equality, and over- + // invalidating errs toward dropping a stale reply rather than applying one. + await act(async () => { first.resolve(batchResponse(BATCH_A)); }); + expect(shownBatch()).toBe('none'); + warn.mockRestore(); + }); +}); diff --git a/src/CourseBulkUnenroll/index.scss b/src/CourseBulkUnenroll/index.scss new file mode 100644 index 000000000..85d752b2d --- /dev/null +++ b/src/CourseBulkUnenroll/index.scss @@ -0,0 +1,103 @@ +// Mirrors CourseTeamManagement's conventions so the two tools in this app read as +// one product. The genuinely generic CTM classes — .sorted-header, +// .custom-table-actions-container, .custom-table-filter-actions, +// .custom-table-data-actions, .change-confirm-modal — are reused as-is: every +// feature stylesheet is imported globally by src/index.scss, so they are already +// live app-wide. Only the rules CTM name-scopes to itself are mirrored here. +// +// Anything Paragon already ships as a utility is applied at the call site instead +// of declared here (mb-3, mb-4.5, mr-2, p-2, text-gray-700, the flex trio). What +// is left is what no utility expresses: overrides of Paragon's own DataTable +// internals, and the sizes that fall between spacer steps. + +// 2.5rem sits between Paragon's mt-4.5 (2rem) and mt-5 (3rem), so there is no +// utility for it. +.course-bulk-unenroll-header { + margin-top: 2.5rem; +} + +// Matches .course-team-management-courses-table-title / -description. +// The bottom margin is `mb-3` at the call sites. +.course-bulk-unenroll-section-title { + color: #000; + font-size: 1.5rem; + font-weight: 600; + line-height: 1.875rem; +} + +// Colour is `text-gray-700` (Paragon's --pgn-color-gray-700 *is* #454545) and +// spacing is `mb-4.5`, both at the call sites. 1.125rem is Paragon's base font +// size, so only the tighter-than-base line height is left to declare. +.course-bulk-unenroll-section-description { + line-height: 1.575rem; +} + +// The CTM control-bar treatment: hide Paragon's own left actions and row-count +// status so the custom tableActions element owns the whole bar. +.course-bulk-unenroll-table { + .pgn__data-table-actions-left { + display: none; + } + + .pgn__data-table-status { + display: none; + } + + .pgn__table-actions, + .pgn__data-table-actions-right, + .pgn__data-table-actions { + display: block; + + p { + margin: 0; + } + } + + .pgn__data-table-empty { + margin-left: .5rem; + } + + th, td { + white-space: nowrap; + } + + // The error column is the one that carries prose, so let it wrap rather than + // pushing the table into a horizontal scroll for a single long message. + td.error-cell { + white-space: normal; + min-width: 16rem; + } +} + +.course-bulk-unenroll { + // Fixed label column so the detail rows line up as a table would, without + // giving up the flex layout that lets a long reason wrap on its own. No + // utility covers flex-basis; the gutter is `mr-2`. + .bulk-unenroll-detail-label { + flex: 0 0 7rem; + } +} + +// Matches .course-team-management-course-status-badge. The flex trio +// (d-inline-flex justify-content-center align-items-center) and the padding +// (p-2) are utilities at the call sites; these are the sizes that are not on +// any scale. +.course-bulk-unenroll-status-badge { + min-width: 4.375rem; + height: 1.5rem; + font-family: inherit; + font-size: .75rem; + font-weight: 500; + line-height: 1.25rem; +} + +// The confirm modal renders in a portal outside the page container, so these +// rules are top-level rather than nested under .course-bulk-unenroll. It also +// carries CTM's .change-confirm-modal, which supplies the dividers and width. +.bulk-unenroll-confirm-modal { + .bulk-unenroll-confirm-count { + font-size: 2.5rem; + font-weight: 700; + line-height: 1.1; + } +} diff --git a/src/CourseBulkUnenroll/messages.js b/src/CourseBulkUnenroll/messages.js new file mode 100644 index 000000000..b7e1e5e45 --- /dev/null +++ b/src/CourseBulkUnenroll/messages.js @@ -0,0 +1,455 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + pageTitle: { + id: 'courseBulkUnenrollPageTitle', + defaultMessage: 'Bulk Unenroll', + description: 'Bulk unenroll page title', + }, + pageDescription: { + id: 'courseBulkUnenrollPageDescription', + defaultMessage: 'Upload a CSV of course IDs to remove all active learners from those courses.', + description: 'Bulk unenroll page description', + }, + + // --- upload step --- + fileLabel: { + id: 'courseBulkUnenrollFileLabel', + defaultMessage: 'Course ID CSV file', + description: 'Label for the CSV file picker', + }, + fileHelpText: { + id: 'courseBulkUnenrollFileHelpText', + defaultMessage: 'One course ID per row, up to {maxRows} rows and {maxMegabytes} MB.', + description: 'Help text under the CSV file picker', + }, + uploadButton: { + id: 'courseBulkUnenrollUploadButton', + defaultMessage: 'Upload and preview', + description: 'Button that uploads the CSV for a preview', + }, + uploadingButton: { + id: 'courseBulkUnenrollUploadingButton', + defaultMessage: 'Uploading…', + description: 'Upload button label while the upload is in flight', + }, + noFileSelected: { + id: 'courseBulkUnenrollNoFileSelected', + defaultMessage: 'Choose a CSV file first.', + description: 'Shown when upload is attempted with no file selected', + }, + startOver: { + id: 'courseBulkUnenrollStartOver', + defaultMessage: 'Start over', + description: 'Button that discards the current preview and returns to the file picker', + }, + + // --- preview step --- + previewHeading: { + id: 'courseBulkUnenrollPreviewHeading', + defaultMessage: 'Preview', + description: 'Heading for the dry-run preview section', + }, + previewSummary: { + id: 'courseBulkUnenrollPreviewSummary', + defaultMessage: 'This will unenroll {learners} learners across {courses} courses.', + description: 'Summary sentence above the preview table', + }, + rejectedRowsHeading: { + id: 'courseBulkUnenrollRejectedRowsHeading', + defaultMessage: 'Rows that could not be read ({count})', + description: 'Heading for the list of rejected CSV rows', + }, + rejectedRow: { + id: 'courseBulkUnenrollRejectedRow', + defaultMessage: 'Row {row}: {message}', + description: 'A single rejected CSV row with its line number', + }, + columnCourseId: { + id: 'courseBulkUnenrollColumnCourseId', + defaultMessage: 'Course ID', + description: 'Preview table column header for the course ID', + }, + columnLearners: { + id: 'courseBulkUnenrollColumnLearners', + defaultMessage: 'Active learners', + description: 'Preview table column header for the active learner count', + }, + columnState: { + id: 'courseBulkUnenrollColumnState', + defaultMessage: 'Status', + description: 'Table column header for the per-course status', + }, + columnUnenrolled: { + id: 'courseBulkUnenrollColumnUnenrolled', + defaultMessage: 'Unenrolled', + description: 'Table column header for learners unenrolled so far', + }, + columnFailed: { + id: 'courseBulkUnenrollColumnFailed', + defaultMessage: 'Failed', + description: 'Table column header for learners that could not be unenrolled', + }, + columnError: { + id: 'courseBulkUnenrollColumnError', + defaultMessage: 'Error', + description: 'Table column header for a per-course error message', + }, + noCourses: { + id: 'courseBulkUnenrollNoCourses', + defaultMessage: 'No courses to show.', + description: 'Empty state for the course table', + }, + + // --- confirm step --- + reasonLabel: { + id: 'courseBulkUnenrollReasonLabel', + defaultMessage: 'Reason (required)', + description: 'Label for the required reason field', + }, + reasonHelpText: { + id: 'courseBulkUnenrollReasonHelpText', + defaultMessage: 'Recorded with the batch so this action can be traced later.', + description: 'Help text under the reason field', + }, + reasonRequired: { + id: 'courseBulkUnenrollReasonRequired', + defaultMessage: 'A reason is required.', + description: 'Validation message when the reason is blank', + }, + confirmButton: { + id: 'courseBulkUnenrollConfirmButton', + defaultMessage: 'Unenroll learners', + description: 'Button that opens the confirmation modal', + }, + confirmModalTitle: { + id: 'courseBulkUnenrollConfirmModalTitle', + defaultMessage: 'Confirm bulk unenrollment', + description: 'Title of the confirmation modal', + }, + confirmModalBody: { + id: 'courseBulkUnenrollConfirmModalBody', + defaultMessage: 'across {courses} courses. This cannot be undone.', + description: 'Body text of the confirmation modal, following the large learner count', + }, + confirmModalConfirm: { + id: 'courseBulkUnenrollConfirmModalConfirm', + defaultMessage: 'Yes, unenroll them', + description: 'Final confirm button inside the modal', + }, + confirmModalConfirming: { + id: 'courseBulkUnenrollConfirmModalConfirming', + defaultMessage: 'Starting…', + description: 'Confirm button label while the batch is being started', + }, + confirmModalConfirmed: { + id: 'courseBulkUnenrollConfirmModalConfirmed', + defaultMessage: 'Started', + description: 'Confirm button label once the batch has been started', + }, + confirmModalCancel: { + id: 'courseBulkUnenrollConfirmModalCancel', + defaultMessage: 'Go back', + description: 'Button that dismisses the confirmation modal', + }, + + // --- progress step --- + progressHeading: { + id: 'courseBulkUnenrollProgressHeading', + defaultMessage: 'Batch progress', + description: 'Heading for the progress view', + }, + progressCourses: { + id: 'courseBulkUnenrollProgressCourses', + defaultMessage: '{finished} of {total} courses finished', + description: 'Course-level progress summary', + }, + progressLearners: { + id: 'courseBulkUnenrollProgressLearners', + defaultMessage: '{unenrolled} learners unenrolled', + description: 'Learner-level progress summary', + }, + progressAlreadyInactive: { + id: 'courseBulkUnenrollProgressAlreadyInactive', + defaultMessage: '{count} already inactive', + description: 'Count of learners that were already unenrolled', + }, + progressFailures: { + id: 'courseBulkUnenrollProgressFailures', + defaultMessage: '{count} failures', + description: 'Count of learners that could not be unenrolled', + }, + batchStateLabel: { + id: 'courseBulkUnenrollBatchStateLabel', + defaultMessage: 'Status: {state}', + description: 'Current batch state', + }, + cancelButton: { + id: 'courseBulkUnenrollCancelButton', + defaultMessage: 'Cancel batch', + description: 'Button that stops a running batch', + }, + retryButton: { + id: 'courseBulkUnenrollRetryButton', + defaultMessage: 'Retry failed courses', + description: 'Button that re-runs the failed courses of a batch', + }, + cancelledNotice: { + id: 'courseBulkUnenrollCancelledNotice', + defaultMessage: 'This batch was cancelled. Learners already unenrolled stay unenrolled; ' + + 'the remaining courses were not processed.', + description: 'Explains what cancelling did, shown on a cancelled batch', + }, + + // --- table controls (shared by all three tables) --- + searchPlaceholder: { + id: 'courseBulkUnenrollSearchPlaceholder', + defaultMessage: 'Search course ID', + description: 'Placeholder in the preview table search box', + }, + tableNoOfEntriesShowingLabel: { + id: 'courseBulkUnenrollTableNoOfEntriesShowingLabel', + defaultMessage: 'Showing {startItemIndex} - {endItemIndex} of {totalFilteredItems}', + description: 'Count of the rows currently visible in a table', + }, + filterAll: { + id: 'courseBulkUnenrollFilterAll', + defaultMessage: 'All statuses', + description: 'Unfiltered choice in a table status filter', + }, + filterValid: { + id: 'courseBulkUnenrollFilterValid', + defaultMessage: 'Found', + description: 'Preview filter choice for courses that exist', + }, + filterNotFound: { + id: 'courseBulkUnenrollFilterNotFound', + defaultMessage: 'Not found', + description: 'Preview filter choice for course IDs the server could not find', + }, + stateValidated: { + id: 'courseBulkUnenrollStateValidated', + defaultMessage: 'Validated', + description: 'Label for the validated state', + }, + statePending: { + id: 'courseBulkUnenrollStatePending', + defaultMessage: 'Pending', + description: 'Label for the pending state', + }, + stateRunning: { + id: 'courseBulkUnenrollStateRunning', + defaultMessage: 'Running', + description: 'Label for the running state', + }, + stateSucceeded: { + id: 'courseBulkUnenrollStateSucceeded', + defaultMessage: 'Succeeded', + description: 'Label for the succeeded state', + }, + statePartial: { + id: 'courseBulkUnenrollStatePartial', + defaultMessage: 'Partial', + description: 'Label for the partial state', + }, + stateFailed: { + id: 'courseBulkUnenrollStateFailed', + defaultMessage: 'Failed', + description: 'Label for the failed state', + }, + stateCancelled: { + id: 'courseBulkUnenrollStateCancelled', + defaultMessage: 'Cancelled', + description: 'Label for the cancelled state', + }, + stateSkipped: { + id: 'courseBulkUnenrollStateSkipped', + defaultMessage: 'Skipped', + description: 'Label for the skipped state', + }, + + // --- batch list --- + batchListHeading: { + id: 'courseBulkUnenrollBatchListHeading', + defaultMessage: 'Batches ({count})', + description: 'Heading for the list of every bulk unenroll batch', + }, + batchListDescription: { + id: 'courseBulkUnenrollBatchListDescription', + defaultMessage: 'Every batch, newest first. Open one to see its progress and details.', + description: 'Explanatory text under the batch list heading', + }, + noBatches: { + id: 'courseBulkUnenrollNoBatches', + defaultMessage: 'No batches match this filter.', + description: 'Empty state for the batch list when a status filter excludes everything', + }, + noBatchesYet: { + id: 'courseBulkUnenrollNoBatchesYet', + defaultMessage: 'No batches have been run yet.', + description: 'Empty state for the batch list before any batch exists', + }, + batchesPageTitle: { + id: 'courseBulkUnenrollBatchesPageTitle', + defaultMessage: 'Bulk Unenroll Batches', + description: 'Title of the batch history page', + }, + backToUpload: { + id: 'courseBulkUnenrollBackToUpload', + defaultMessage: 'Back to bulk unenroll', + description: 'Link from the batch history page, or from a batch being watched, back to the upload page', + }, + viewBatchHistory: { + id: 'courseBulkUnenrollViewBatchHistory', + defaultMessage: 'View batch history', + description: 'Link from the upload page to the batch history page', + }, + columnReason: { + id: 'courseBulkUnenrollColumnReason', + defaultMessage: 'Reason', + description: 'Batch list column header for the operator-supplied reason', + }, + columnRequester: { + id: 'courseBulkUnenrollColumnRequester', + defaultMessage: 'Started by', + description: 'Batch list column header for the user who started the batch', + }, + columnFile: { + id: 'courseBulkUnenrollColumnFile', + defaultMessage: 'File', + description: 'Batch list column header for the uploaded CSV filename', + }, + columnCourses: { + id: 'courseBulkUnenrollColumnCourses', + defaultMessage: 'Courses', + description: 'Batch list column header for the batch course count', + }, + columnStarted: { + id: 'courseBulkUnenrollColumnStarted', + defaultMessage: 'Started', + description: 'Batch list column header for when the batch was created', + }, + openBatch: { + id: 'courseBulkUnenrollOpenBatch', + defaultMessage: 'Open', + description: 'Button on a batch list row that opens that batch', + }, + + // --- batch details --- + detailsHeading: { + id: 'courseBulkUnenrollDetailsHeading', + defaultMessage: 'Batch details', + description: 'Heading for the batch metadata block on the progress view', + }, + detailsReason: { + id: 'courseBulkUnenrollDetailsReason', + defaultMessage: 'Reason', + description: 'Metadata label for the operator-supplied reason', + }, + detailsNoReason: { + id: 'courseBulkUnenrollDetailsNoReason', + defaultMessage: 'Not recorded', + description: 'Shown in place of the reason when the batch has none', + }, + detailsRequester: { + id: 'courseBulkUnenrollDetailsRequester', + defaultMessage: 'Started by', + description: 'Metadata label for the user who started the batch', + }, + detailsFile: { + id: 'courseBulkUnenrollDetailsFile', + defaultMessage: 'File', + description: 'Metadata label for the uploaded CSV filename', + }, + detailsStarted: { + id: 'courseBulkUnenrollDetailsStarted', + defaultMessage: 'Started', + description: 'Metadata label for when the batch was created', + }, + detailsFinished: { + id: 'courseBulkUnenrollDetailsFinished', + defaultMessage: 'Finished', + description: 'Metadata label for when a completed batch stopped', + }, + detailsLastUpdated: { + id: 'courseBulkUnenrollDetailsLastUpdated', + defaultMessage: 'Last updated', + description: 'Metadata label for the last change to a batch still in flight', + }, + detailsBatchId: { + id: 'courseBulkUnenrollDetailsBatchId', + defaultMessage: 'Batch ID', + description: 'Metadata label for the batch id', + }, + + // --- batch lookup --- + lookupHeading: { + id: 'courseBulkUnenrollLookupHeading', + defaultMessage: 'Look up an existing batch', + description: 'Heading for the batch lookup entry point', + }, + lookupLabel: { + id: 'courseBulkUnenrollLookupLabel', + defaultMessage: 'Batch ID', + description: 'Label for the batch ID lookup field', + }, + lookupButton: { + id: 'courseBulkUnenrollLookupButton', + defaultMessage: 'Open batch', + description: 'Button that opens an existing batch by ID', + }, + + // --- errors --- + uploadError: { + id: 'courseBulkUnenrollUploadError', + defaultMessage: 'The file could not be uploaded.', + description: 'Generic upload failure message', + }, + fileTooLargeError: { + id: 'courseBulkUnenrollFileTooLargeError', + defaultMessage: 'That file is too large. The limit is {maxMegabytes} MB.', + description: 'Shown when the server rejects the file for size', + }, + tooManyRowsError: { + id: 'courseBulkUnenrollTooManyRowsError', + defaultMessage: 'That file has too many rows. The limit is {maxRows}.', + description: 'Shown when the server rejects the file for row count', + }, + confirmError: { + id: 'courseBulkUnenrollConfirmError', + defaultMessage: 'The batch could not be confirmed.', + description: 'Generic confirm failure message', + }, + statusError: { + id: 'courseBulkUnenrollStatusError', + defaultMessage: 'The batch status could not be loaded.', + description: 'Generic status fetch failure message', + }, + listError: { + id: 'courseBulkUnenrollListError', + defaultMessage: 'The list of batches could not be loaded.', + description: 'Generic batch list fetch failure message', + }, + batchNotFoundError: { + id: 'courseBulkUnenrollBatchNotFoundError', + defaultMessage: 'No batch found with that ID.', + description: 'Shown when a batch ID does not exist', + }, + cancelError: { + id: 'courseBulkUnenrollCancelError', + defaultMessage: 'The batch could not be cancelled.', + description: 'Generic cancel failure message', + }, + retryError: { + id: 'courseBulkUnenrollRetryError', + defaultMessage: 'The failed courses could not be retried.', + description: 'Generic retry failure message', + }, + permissionError: { + id: 'courseBulkUnenrollPermissionError', + defaultMessage: 'You do not have permission to use this tool.', + description: 'Shown when the API rejects the user as not global staff', + }, +}); + +export default messages; diff --git a/src/CourseBulkUnenroll/utils.js b/src/CourseBulkUnenroll/utils.js new file mode 100644 index 000000000..26b995af6 --- /dev/null +++ b/src/CourseBulkUnenroll/utils.js @@ -0,0 +1,54 @@ +import messages from './messages'; + +/** + * One label per state, used by the status badges on both tables. + * + * Batch and course states share most of their vocabulary, so a single map covers + * both rather than two that would drift. Keyed by the raw API value, which is + * what every caller already holds. The filter dropdowns do NOT read this — their + * options carry their own labels, because a filter choice can span two states. + */ +const STATE_MESSAGES = { + validated: messages.stateValidated, + pending: messages.statePending, + running: messages.stateRunning, + succeeded: messages.stateSucceeded, + partial: messages.statePartial, + failed: messages.stateFailed, + cancelled: messages.stateCancelled, + skipped: messages.stateSkipped, +}; + +/** + * Translate a state value, falling back to the raw value. + * + * The fallback is deliberate: a state the backend adds later should show up as + * itself rather than vanish into a blank cell. + */ +export function stateLabel(intl, value) { + const message = STATE_MESSAGES[value]; + return message ? intl.formatMessage(message) : value; +} + +/** + * Render an API timestamp in the operator's locale. + * + * Explicit field options rather than `dateStyle`/`timeStyle` so the output is + * stable across react-intl versions. Returns an em dash for a missing value: + * these are optional details, and "Invalid Date" in a metadata row is worse + * than an obvious blank. + */ +export function formatTimestamp(intl, value) { + if (!value) { return '—'; } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { return '—'; } + return intl.formatDate(date, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +export default formatTimestamp; diff --git a/src/data/constants/routes.js b/src/data/constants/routes.js index 238889d35..ffcac736c 100644 --- a/src/data/constants/routes.js +++ b/src/data/constants/routes.js @@ -7,6 +7,8 @@ const ROUTES = { FEATURE_BASED_ENROLLMENTS: '/feature-based-enrollments', PROGRAM_ENROLLMENTS: '/program-enrollments', COURSE_TEAM_MANAGEMENT: '/course_team_management', + COURSE_BULK_UNENROLL: '/course_bulk_unenroll', + COURSE_BULK_UNENROLL_BATCHES: '/course_bulk_unenroll/batches', }, }, CONFIGURATION: { diff --git a/src/index.jsx b/src/index.jsx index 81827d23c..26052bf64 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -19,6 +19,8 @@ import FBEIndexPage from './FeatureBasedEnrollments/FeatureBasedEnrollmentIndexP import UserMessagesProvider from './userMessages/UserMessagesProvider'; import ProgramEnrollmentsIndexPage from './ProgramEnrollments/ProgramEnrollmentsIndexPage'; import CourseTeamManagementIndexPage from './CourseTeamManagement/CourseTeamManagementIndexPage'; +import CourseBulkUnenrollIndexPage from './CourseBulkUnenroll/CourseBulkUnenrollIndexPage'; +import CourseBulkUnenrollBatchesPage from './CourseBulkUnenroll/CourseBulkUnenrollBatchesPage'; import Head from './head/Head'; import CustomersPage from './Configuration/Customers/CustomerDataTable/CustomersPage'; @@ -105,6 +107,14 @@ subscribe(APP_READY, () => { path={SUPPORT_TOOLS_TABS.SUB_DIRECTORY.COURSE_TEAM_MANAGEMENT} element={} /> + } + /> + } + /> , diff --git a/src/index.scss b/src/index.scss index 9988e24ce..fe9150373 100755 --- a/src/index.scss +++ b/src/index.scss @@ -7,3 +7,5 @@ @import "./Configuration/index.scss"; @import "./CourseTeamManagement/index.scss"; + +@import "./CourseBulkUnenroll/index.scss"; diff --git a/src/supportHeader/Header.jsx b/src/supportHeader/Header.jsx index 3923e57d2..4d1895b5f 100644 --- a/src/supportHeader/Header.jsx +++ b/src/supportHeader/Header.jsx @@ -89,6 +89,7 @@ export default function Header() { + ), },