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 (
+
+ {/* 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 (
+
+
+
+ {/* 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 && (
+
+ )}
+
+ {/* 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.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)}
+
+ )}
+
+
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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 (
+
+
+ {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() {