Skip to content
Draft
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
4 changes: 1 addition & 3 deletions src/Main.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { CurrentAppProvider, getAppConfig } from '@openedx/frontend-base';

import { appId } from './constants';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Outlet } from 'react-router-dom';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { appId } from './constants';
import './main.scss';

const queryClient = new QueryClient();
Expand Down
19 changes: 19 additions & 0 deletions src/components/SpecifyLearnerField/SpecifyLearnerField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Button, FormControl, FormGroup, FormLabel } from '@openedx/paragon';

interface SpecifyLearnerFieldProps {
onChange: (value: string) => void,
}

const SpecifyLearnerField = ({ onChange }: SpecifyLearnerFieldProps) => {
return (
<FormGroup size="sm">
<FormLabel>Specify Learner:</FormLabel>
<div className="d-flex">
<FormControl className="mr-2" name="email_or_username" placeholder="Learner email, address or username" size="md" autoResize onChange={(e) => onChange(e.target.value)} />
<Button>Select</Button>
</div>
</FormGroup>
);
};

export default SpecifyLearnerField;
124 changes: 124 additions & 0 deletions src/dateExtensions/DateExtensionsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@openedx/frontend-base';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import DateExtensionsPage from './DateExtensionsPage';
import { useDateExtensions, useGradedSubsections, useAddDateExtensionMutation, useResetDateExtensionMutation } from './data/apiHook';

jest.mock('./data/apiHook', () => ({
useDateExtensions: jest.fn(),
useResetDateExtensionMutation: jest.fn(),
useAddDateExtensionMutation: jest.fn(() => ({ mutate: jest.fn() })),
useGradedSubsections: jest.fn(),
}));

const mockDateExtensions = [
{
id: 1,
username: 'edByun',
fullname: 'Ed Byun',
email: '[email protected]',
graded_subsection: 'Three body diagrams',
extended_due_date: '2026-07-15'
},
];

const mockGradedSubsections = [
{
subsectionId: 'subsection-1block-v1:edX+DemoX+2015+type@problem+block@618c5933b8b544e4a4cc103d3e508378',
displayName: 'Three body diagrams'
}
];

const mutateMock = jest.fn();

describe('DateExtensionsPage', () => {
beforeEach(() => {
(useDateExtensions as jest.Mock).mockReturnValue({
data: { count: mockDateExtensions.length, results: mockDateExtensions },
isLoading: false,
});
(useResetDateExtensionMutation as jest.Mock).mockReturnValue({
mutate: mutateMock,
});
(useAddDateExtensionMutation as jest.Mock).mockReturnValue({
mutate: jest.fn(),
});
(useGradedSubsections as jest.Mock).mockReturnValue({
data: { items: mockGradedSubsections },
isLoading: false,
});
});

const RenderWithRouter = () => (
<IntlProvider messages={{}}>
<MemoryRouter initialEntries={['/course-v1:edX+DemoX+Demo_Course']}>
<Routes>
<Route path="/:courseId" element={<DateExtensionsPage />} />
</Routes>
</MemoryRouter>
</IntlProvider>
);

it('renders page title', () => {
render(<RenderWithRouter />);
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
});

it('renders add extension button', () => {
render(<RenderWithRouter />);
expect(screen.getByRole('button', { name: /add individual extension/i })).toBeInTheDocument();
});

it('renders date extensions list', () => {
render(<RenderWithRouter />);
expect(screen.getByText('Ed Byun')).toBeInTheDocument();
expect(screen.getByRole('cell', { name: 'Three body diagrams' })).toBeInTheDocument();
});

it('shows loading state on table when fetching data', () => {
(useDateExtensions as jest.Mock).mockReturnValue({
data: { count: 0, results: [] },
isLoading: true,
});
render(<RenderWithRouter />);
expect(screen.getByRole('status')).toBeInTheDocument();
});

it('renders reset link for each row', () => {
render(<RenderWithRouter />);
const resetLinks = screen.getAllByRole('button', { name: 'Reset Extensions' });
expect(resetLinks).toHaveLength(mockDateExtensions.length);
});

it('opens reset modal when reset button is clicked', async () => {
render(<RenderWithRouter />);
const user = userEvent.setup();
const resetButton = screen.getByRole('button', { name: 'Reset Extensions' });
await user.click(resetButton);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText(/reset extensions for/i)).toBeInTheDocument();
const confirmButton = screen.getByRole('button', { name: /reset due date/i });
expect(confirmButton).toBeInTheDocument();
});

it('calls reset mutation when confirm reset is clicked', async () => {
render(<RenderWithRouter />);
const user = userEvent.setup();
const resetButton = screen.getByRole('button', { name: 'Reset Extensions' });
await user.click(resetButton);
const confirmButton = screen.getByRole('button', { name: /reset due date/i });
await user.click(confirmButton);
expect(mutateMock).toHaveBeenCalled();
});

it('closes reset modal when cancel is clicked', async () => {
render(<RenderWithRouter />);
const user = userEvent.setup();
const resetButton = screen.getByRole('button', { name: 'Reset Extensions' });
await user.click(resetButton);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
125 changes: 125 additions & 0 deletions src/dateExtensions/DateExtensionsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useIntl } from '@openedx/frontend-base';
import { AlertModal, Button, Container, FormControl, Icon, Toast } from '@openedx/paragon';
import messages from './messages';
import DateExtensionsList from './components/DateExtensionsList';
import ResetExtensionsModal from './components/ResetExtensionsModal';
import { LearnerDateExtension } from './types';
import { useAddDateExtensionMutation, useResetDateExtensionMutation } from './data/apiHook';
import AddExtensionModal from './components/AddExtensionModal';
import SelectGradedSubsection from './components/SelectGradedSubsection';
import { Search } from '@openedx/paragon/icons';

// const successMessage = 'Successfully reset due date for student Phu Nguyen for A subsection with two units (block-v1:SchemaAximWGU+WGU101+1+type@sequential+block@3984030755104708a86592cf23fb1ae4) to 2025-08-21 00:00';

const DateExtensionsPage = () => {
const intl = useIntl();
const { courseId = '' } = useParams<{ courseId: string }>();
const { mutate: resetMutation } = useResetDateExtensionMutation();
const { mutate: addExtensionMutation } = useAddDateExtensionMutation();
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<LearnerDateExtension | null>(null);
const [successMessage, setSuccessMessage] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>('');
const [isAddExtensionModalOpen, setIsAddExtensionModalOpen] = useState(false);
const [searchedLearner, setSearchedLearner] = useState<string>('');
const [gradedSubsectionFilter, setGradedSubsectionFilter] = useState<string>('');

const handleResetExtensions = (user: LearnerDateExtension) => {
setIsResetModalOpen(true);
setSelectedUser(user);
};

const handleCloseModal = () => {
setIsResetModalOpen(false);
setSelectedUser(null);
};

const handleErrorOnReset = (error: any) => {
setErrorMessage(error.message);
};

const handleSuccessOnReset = (response: any) => {
const { message } = response;
setSuccessMessage(message);
handleCloseModal();
};

const handleConfirmReset = async () => {
if (selectedUser && courseId) {
resetMutation({
courseId,
userId: selectedUser.id
}, {
onError: handleErrorOnReset,
onSuccess: handleSuccessOnReset
});
}
};

const handleOpenAddExtension = () => {
setIsAddExtensionModalOpen(true);
};

const handleAddExtension = ({ email_or_username, block_id, due_datetime, reason }) => {
addExtensionMutation({ courseId, extensionData: {
email_or_username,
block_id,
due_datetime,
reason
} }, {
onError: handleErrorOnReset,
onSuccess: handleSuccessOnReset
});
};

return (
<Container className="mt-4.5 mb-4 mx-4" fluid="xl">
<h3>{intl.formatMessage(messages.dateExtensionsTitle)}</h3>
<div className="d-flex align-items-center justify-content-between mb-3.5">
<div className="d-flex">
<FormControl
onChange={(e) => setSearchedLearner(e.target.value)}
placeholder={intl.formatMessage(messages.searchLearnerPlaceholder)}
trailingElement={<Icon src={Search} />}
value={searchedLearner}
/>
<SelectGradedSubsection
placeholder={intl.formatMessage(messages.allGradedSubsections)}
onChange={(e) => setGradedSubsectionFilter(e.target.value)}
value={gradedSubsectionFilter}
/>
</div>
<Button onClick={handleOpenAddExtension}>+ {intl.formatMessage(messages.addIndividualExtension)}</Button>
</div>
<DateExtensionsList
searchedLearner={searchedLearner}
gradedSubsectionFilter={gradedSubsectionFilter}
onResetExtensions={handleResetExtensions}
/>
<AddExtensionModal
isOpen={isAddExtensionModalOpen}
title={intl.formatMessage(messages.addIndividualDueDateExtension)}
onClose={() => setIsAddExtensionModalOpen(false)}
onSubmit={handleAddExtension}
/>
<ResetExtensionsModal
isOpen={isResetModalOpen}
message={intl.formatMessage(messages.resetConfirmationMessage)}
title={intl.formatMessage(messages.resetConfirmationHeader, { username: selectedUser?.username })}
onCancelReset={handleCloseModal}
onClose={handleCloseModal}
onConfirmReset={handleConfirmReset}
/>
<Toast show={!!successMessage} onClose={() => {}} className="text-break">
{successMessage}
</Toast>
<AlertModal title={errorMessage} isOpen={!!errorMessage} footerNode={<Button onClick={() => setErrorMessage('')}>{intl.formatMessage(messages.close)}</Button>}>
{errorMessage}
</AlertModal>
</Container>
);
};

export default DateExtensionsPage;
103 changes: 103 additions & 0 deletions src/dateExtensions/components/AddExtensionModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { useState } from 'react';
import { ActionRow, Button, Form, FormControl, FormGroup, FormLabel, ModalDialog } from '@openedx/paragon';
import { useIntl } from '@openedx/frontend-base';
import SpecifyLearnerField from '../../components/SpecifyLearnerField/SpecifyLearnerField';
import messages from '../messages';
import SelectGradedSubsection from './SelectGradedSubsection';

interface AddExtensionModalProps {
isOpen: boolean,
title: string,
onClose: () => void,
onSubmit: ({ email_or_username, block_id, due_datetime, reason }: {
email_or_username: string,
block_id: string,
due_datetime: string,
reason: string,
}) => void,
}

const AddExtensionModal = ({ isOpen, title, onClose, onSubmit }: AddExtensionModalProps) => {
const intl = useIntl();
const [formData, setFormData] = useState({
email_or_username: '',
block_id: '',
due_date: '',
due_time: '',
reason: '',
});

const handleSubmit = (event) => {
event.preventDefault();
const { email_or_username, block_id, due_date, due_time, reason } = formData;
onSubmit({
email_or_username,
block_id,
due_datetime: `${due_date} ${due_time}`,
reason
});
};

const onChange = (event) => {
const { name, value } = event.target;
setFormData((prevData) => ({
...prevData,
[name]: value,
}));
};

return (
<ModalDialog isOpen={isOpen} onClose={onClose} title={title} isOverflowVisible={false} size="xl">
<Form onSubmit={handleSubmit}>
<ModalDialog.Header className="p-3 pl-4">
<h3>{title}</h3>
</ModalDialog.Header>
<ModalDialog.Body className="border-bottom border-top">
<div className="pt-3">
<p>{intl.formatMessage(messages.extensionInstructions)}</p>
<div className="container-fluid border-bottom mb-4.5 pb-3">
<div className="row">
<div className="col-sm-12 col-md-6">
<SpecifyLearnerField onChange={() => {}} />
</div>
<div className="col-sm-12 col-md-4">
<SelectGradedSubsection
label={intl.formatMessage(messages.selectGradedSubsection)}
placeholder={intl.formatMessage(messages.selectGradedSubsection)}
onChange={onChange}
/>
</div>
</div>
</div>
<div>
<h4>{intl.formatMessage(messages.defineExtension)}</h4>
<FormGroup size="sm">
<FormLabel>
{intl.formatMessage(messages.extensionDate)}:
</FormLabel>
<div className="d-md-flex w-md-50 align-items-center">
<FormControl name="due_date" type="date" size="md" />
<FormControl name="due_time" type="time" size="md" className="mt-sm-3 mt-md-0" />
</div>
</FormGroup>
<FormGroup className="mt-3" size="sm">
<FormLabel>
{intl.formatMessage(messages.reasonForExtension)}:
</FormLabel>
<FormControl name="reason" placeholder={intl.formatMessage(messages.reasonForExtension)} size="md" />
</FormGroup>
</div>
</div>
</ModalDialog.Body>
<ModalDialog.Footer className="p-4">
<ActionRow>
<Button variant="tertiary" onClick={onClose}>{intl.formatMessage(messages.cancel)}</Button>
<Button type="submit">{intl.formatMessage(messages.addExtension)}</Button>
</ActionRow>
</ModalDialog.Footer>
</Form>
</ModalDialog>
);
};

export default AddExtensionModal;
Loading