Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions src/CourseBulkUnenroll/BatchListPanel.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Badge
className="course-bulk-unenroll-status-badge d-inline-flex justify-content-center align-items-center p-2"
variant={BATCH_STATE_VARIANTS[state] ?? 'light'}
>
{stateLabel(intl, state)}
</Badge>
);
}
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 (
<Button
variant="outline-primary"
size="sm"
onClick={() => column.onOpen(row.original.batch_id)}
data-testid={`bulk-unenroll-open-${row.original.batch_id}`}
>
{intl.formatMessage(messages.openBatch)}
</Button>
);
}
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 (
<Alert variant="danger" data-testid="bulk-unenroll-batch-list-error">
{error.error?.[0]?.text ?? intl.formatMessage(messages.listError)}
</Alert>
);
}

// `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 (
<div className="d-flex align-items-center" data-testid="bulk-unenroll-batch-list-loading">
<Spinner animation="border" className="mr-2" screenReaderText="loading" />
{intl.formatMessage(messages.batchListHeading, { count: 0 })}
</div>
);
}

const tableActions = (
<TableActions
pageIndex={tableState.pageIndex}
pageSize={tableState.pageSize}
totalItems={count}
>
<StateFilterDropdown
id="bulk-unenroll-batch-filter"
testId="bulk-unenroll-batch-filter"
value={filter}
onChange={(value) => {
setFilter(value);
setTableState((prev) => ({ ...prev, pageIndex: 0 }));
}}
options={BATCH_STATE_FILTER_OPTIONS}
/>
</TableActions>
);

return (
<div className="mb-4" data-testid="bulk-unenroll-batch-list">
<Stack direction="horizontal" gap={2} className="align-items-center mb-1">
<header className="course-bulk-unenroll-section-title">
{intl.formatMessage(messages.batchListHeading, { count })}
</header>
{isLoading && <Spinner animation="border" size="sm" screenReaderText="loading" />}
</Stack>
<div className="course-bulk-unenroll-section-description text-gray-700 mb-4.5">
<p>{intl.formatMessage(messages.batchListDescription)}</p>
</div>

<div className="course-bulk-unenroll-table">
<DataTable
isLoading={isLoading}
isPaginated
manualPagination
manualFilters
isFilterable
initialState={tableState}
state={tableState}
// A plain useState setter, deliberately: Paragon's fetchData effect
// depends on this callback's identity, so anything rebuilt per render
// would re-fire it forever.
fetchData={setTableState}
pageCount={Math.ceil(count / tableState.pageSize)}
itemCount={count}
tableActions={[tableActions]}
data={batches}
columns={[
{
Header: intl.formatMessage(messages.columnState),
accessor: 'state',
Cell: StateCell,
disableFilters: true,
},
{ Header: intl.formatMessage(messages.columnReason), accessor: 'reason', disableFilters: true },
{ Header: intl.formatMessage(messages.columnRequester), accessor: 'requester', disableFilters: true },
{ Header: intl.formatMessage(messages.columnFile), accessor: 'csv_filename', disableFilters: true },
{ Header: intl.formatMessage(messages.columnCourses), accessor: 'total_courses', disableFilters: true },
{
Header: intl.formatMessage(messages.columnStarted),
accessor: 'created',
Cell: StartedCell,
disableFilters: true,
},
{
Header: '', id: 'open', onOpen, Cell: OpenCell, disableFilters: true,
},
]}
>
<DataTable.TableControlBar />
{batches.length > 0 && <DataTable.Table />}
{batches.length === 0 && (
<div className="pgn__data-table-empty" data-testid="bulk-unenroll-batch-list-empty">
{/* 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)}
</div>
)}
{count > 0 && <DataTable.TableFooter />}
</DataTable>
</div>
</div>
);
}

BatchListPanel.propTypes = {
onOpen: PropTypes.func.isRequired,
};
62 changes: 62 additions & 0 deletions src/CourseBulkUnenroll/BatchMetadata.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="mb-3" data-testid="bulk-unenroll-batch-details">
<h4 className="h5">{intl.formatMessage(messages.detailsHeading)}</h4>
<dl className="row mb-0 small">
{rows.map(([label, value]) => (
<div className="col-12 col-md-6 d-flex mb-1" key={label.id}>
<dt className="text-muted bulk-unenroll-detail-label mr-2">
{intl.formatMessage(label)}
</dt>
<dd className="mb-0" data-testid={`bulk-unenroll-detail-${label.id}`}>{value}</dd>
</div>
))}
</dl>
</section>
);
}

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,
};
100 changes: 100 additions & 0 deletions src/CourseBulkUnenroll/ConfirmModal.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<ModalLayer
isOpen={isOpen}
onClose={onCancel}
positionRef={positionRef}
isBlocking={isSettling}
>
<div
role="dialog"
aria-label={intl.formatMessage(messages.confirmModalTitle)}
className="p-4 bg-white mx-auto my-5 border rounded-sm change-confirm-modal bulk-unenroll-confirm-modal"
data-testid="bulk-unenroll-confirm-modal"
>
<div className="d-flex justify-content-start align-items-center mb-3">
<h2 className="text-lg font-semibold">{intl.formatMessage(messages.confirmModalTitle)}</h2>
</div>
<div className="mb-3 section-divider-1" />

<p className="mb-1">
<span className="bulk-unenroll-confirm-count" data-testid="bulk-unenroll-confirm-count">
{learnerCount.toLocaleString()}
</span>
</p>
<p className="mb-3">
{intl.formatMessage(messages.confirmModalBody, {
courses: courseCount.toLocaleString(),
})}
</p>
<div className="mb-3 section-divider-2" />

<div className="d-flex justify-content-end align-items-center">
<ModalCloseButton
className="mr-3"
variant="outline-primary"
disabled={isSettling}
data-testid="bulk-unenroll-confirm-cancel"
onClick={onCancel}
>
{intl.formatMessage(messages.confirmModalCancel)}
</ModalCloseButton>
<StatefulButton
variant="danger"
state={submitState}
onClick={onConfirm}
data-testid="bulk-unenroll-confirm-submit"
icons={{
pending: <Icon src={SpinnerSimple} className="icon-spin" />,
complete: <Icon src={CheckCircleOutline} />,
}}
labels={{
default: intl.formatMessage(messages.confirmModalConfirm),
pending: intl.formatMessage(messages.confirmModalConfirming),
complete: intl.formatMessage(messages.confirmModalConfirmed),
}}
/>
</div>
</div>
</ModalLayer>
);
}

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,
};
Loading