Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
72 changes: 53 additions & 19 deletions src/dateExtensions/DateExtensionsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@openedx/frontend-base';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DateExtensionsPage from './DateExtensionsPage';
import { useDateExtensions } from './data/apiHook';
import { useDateExtensions, useResetDateExtensionMutation } from './data/apiHook';
import { renderWithAlertAndIntl } from '@src/testUtils';

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
courseId: 'course-v1:edX+DemoX+Demo_Course',
}),
}));

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

const mockDateExtensions = [
Expand All @@ -19,36 +27,31 @@ const mockDateExtensions = [
},
];

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

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 />);
renderWithAlertAndIntl(<DateExtensionsPage />);
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
});

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

it('renders date extensions list', () => {
render(<RenderWithRouter />);
renderWithAlertAndIntl(<DateExtensionsPage />);
expect(screen.getByText('Ed Byun')).toBeInTheDocument();
expect(screen.getByText('Three body diagrams')).toBeInTheDocument();
});
Expand All @@ -58,13 +61,44 @@ describe('DateExtensionsPage', () => {
data: { count: 0, results: [] },
isLoading: true,
});
render(<RenderWithRouter />);
renderWithAlertAndIntl(<DateExtensionsPage />);
expect(screen.getByRole('status')).toBeInTheDocument();
});

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

it('opens reset modal when reset button is clicked', async () => {
renderWithAlertAndIntl(<DateExtensionsPage />);
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 () => {
renderWithAlertAndIntl(<DateExtensionsPage />);
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 () => {
renderWithAlertAndIntl(<DateExtensionsPage />);
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();
});
});
61 changes: 56 additions & 5 deletions src/dateExtensions/DateExtensionsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,60 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useIntl } from '@openedx/frontend-base';
import { Button } from '@openedx/paragon';
import messages from './messages';
import DateExtensionsList from './components/DateExtensionsList';
import { Button } from '@openedx/paragon';
import ResetExtensionsModal from './components/ResetExtensionsModal';
import { LearnerDateExtension } from './types';

// 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';
import { useResetDateExtensionMutation } from './data/apiHook';
import { useAlert } from '@src/providers/AlertProvider';

const DateExtensionsPage = () => {
const intl = useIntl();
const { courseId } = useParams<{ courseId: string }>();
const { mutate: resetMutation } = useResetDateExtensionMutation();
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<LearnerDateExtension | null>(null);
const { showToast, showModal, removeAlert, clearAlerts } = useAlert();

const handleResetExtensions = (user: LearnerDateExtension) => {
// Implementation for resetting extensions will go here
console.log(user);
clearAlerts();
setIsResetModalOpen(true);
setSelectedUser(user);
};

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

const handleErrorOnReset = (error: any) => {
showModal({
confirmText: intl.formatMessage(messages.close),
message: error.message,
variant: 'danger',
onConfirm: (id) => removeAlert(id)
});
};

const handleSuccessOnReset = (response: string) => {
showToast(response);
handleCloseModal();
};

const handleConfirmReset = async () => {
if (selectedUser && courseId) {
resetMutation({
courseId,
params: {
student: selectedUser.username,
url: selectedUser.unitLocation,
}
}, {
onError: handleErrorOnReset,
onSuccess: handleSuccessOnReset
});
}
};

return (
Expand All @@ -22,6 +65,14 @@ const DateExtensionsPage = () => {
<Button>+ {intl.formatMessage(messages.addIndividualExtension)}</Button>
</div>
<DateExtensionsList onResetExtensions={handleResetExtensions} />
<ResetExtensionsModal
isOpen={isResetModalOpen}
message={intl.formatMessage(messages.resetConfirmationMessage)}
title={intl.formatMessage(messages.resetConfirmationHeader, { username: selectedUser?.username })}
onCancelReset={handleCloseModal}
onClose={handleCloseModal}
onConfirmReset={handleConfirmReset}
/>
</div>
);
};
Expand Down
14 changes: 7 additions & 7 deletions src/dateExtensions/components/DateExtensionsList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const mockData = [
fullName: 'Test User',
email: 'test@example.com',
unitTitle: 'Test Section',
extendedDueDate: '2024-01-01'
extendedDueDate: '2025-11-07T00:00:00Z'
}
];

Expand All @@ -36,11 +36,11 @@ describe('DateExtensionsList', () => {
(useDateExtensions as jest.Mock).mockReturnValue({ isLoading: false, data: { count: mockData.length, results: mockData } });
renderComponent({ onResetExtensions: mockResetExtensions });
const user = userEvent.setup();
expect(screen.getByText('test_user')).toBeInTheDocument();
expect(screen.getByText('Test User')).toBeInTheDocument();
expect(screen.getByText('test@example.com')).toBeInTheDocument();
expect(screen.getByText('Test Section')).toBeInTheDocument();
expect(screen.getByText('2024-01-01')).toBeInTheDocument();
expect(screen.getByText(mockData[0].username)).toBeInTheDocument();
expect(screen.getByText(mockData[0].fullName)).toBeInTheDocument();
expect(screen.getByText(mockData[0].email)).toBeInTheDocument();
expect(screen.getByText(mockData[0].unitTitle)).toBeInTheDocument();
expect(screen.getByText('11/07/2025, 12:00 AM')).toBeInTheDocument();
const resetExtensions = screen.getByRole('button', { name: /reset extensions/i });
expect(resetExtensions).toBeInTheDocument();
await user.click(resetExtensions);
Expand All @@ -50,7 +50,7 @@ describe('DateExtensionsList', () => {
it('renders empty table when no data provided', () => {
(useDateExtensions as jest.Mock).mockReturnValue({ data: { count: 0, results: [] } });
renderComponent({});
expect(screen.queryByText('test_user')).not.toBeInTheDocument();
expect(screen.queryByText(mockData[0].username)).not.toBeInTheDocument();
expect(screen.getByText('No results found')).toBeInTheDocument();
});
});
8 changes: 7 additions & 1 deletion src/dateExtensions/components/DateExtensionsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,13 @@ const DateExtensionsList = ({
{ accessor: 'fullName', Header: intl.formatMessage(messages.fullname) },
{ accessor: 'email', Header: intl.formatMessage(messages.email) },
{ accessor: 'unitTitle', Header: intl.formatMessage(messages.gradedSubsection) },
{ accessor: 'extendedDueDate', Header: intl.formatMessage(messages.extendedDueDate) },
{
accessor: 'extendedDueDate',
Header: intl.formatMessage(messages.extendedDueDate),
Cell: ({ value }: { value: string }) => (
intl.formatDate(value, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })
)
},
];

const additionalColumns = [{
Expand Down
45 changes: 45 additions & 0 deletions src/dateExtensions/components/ResetExtensionsModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ResetExtensionsModal from './ResetExtensionsModal';
import { renderWithIntl } from '../../testUtils';
import messages from '../messages';

describe('ResetExtensionsModal', () => {
const defaultProps = {
isOpen: true,
message: 'Test message',
title: 'Test title',
onCancelReset: jest.fn(),
onClose: jest.fn(),
onConfirmReset: jest.fn(),
};

const renderModal = (props = {}) => renderWithIntl(
<ResetExtensionsModal {...defaultProps} {...props} />
);

it('renders modal with correct title and message', () => {
renderModal();
expect(screen.getByText('Test title')).toBeInTheDocument();
expect(screen.getByText('Test message')).toBeInTheDocument();
});

it('calls onCancelReset when cancel button is clicked', async () => {
const user = userEvent.setup();
renderModal();
await user.click(screen.getByRole('button', { name: messages.cancel.defaultMessage }));
expect(defaultProps.onCancelReset).toHaveBeenCalled();
});

it('calls onConfirmReset when confirm button is clicked', async () => {
const user = userEvent.setup();
renderModal();
await user.click(screen.getByRole('button', { name: messages.confirm.defaultMessage }));
expect(defaultProps.onConfirmReset).toHaveBeenCalled();
});

it('does not render when isOpen is false', () => {
renderModal({ isOpen: false });
expect(screen.queryByText('Test title')).not.toBeInTheDocument();
});
});
35 changes: 35 additions & 0 deletions src/dateExtensions/components/ResetExtensionsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useIntl } from '@openedx/frontend-base';
import { ModalDialog, ActionRow, Button } from '@openedx/paragon';
import messages from '../messages';

interface ResetExtensionsModalProps {
isOpen: boolean,
message: string,
title: string,
onCancelReset: () => void,
onClose: () => void,
onConfirmReset: () => void,
}

const ResetExtensionsModal = ({
isOpen,
message,
title,
onCancelReset,
onClose,
onConfirmReset,
}: ResetExtensionsModalProps) => {
const intl = useIntl();
return (
<ModalDialog isOpen={isOpen} onClose={onClose} hasCloseButton={false} title={title} isOverflowVisible={false} className="p-4">
<h4 className="mb-2.5">{title}</h4>
<p className="mb-2.5">{message}</p>
<ActionRow>
<Button variant="tertiary" onClick={onCancelReset}>{intl.formatMessage(messages.cancel)}</Button>
<Button variant="primary" onClick={onConfirmReset}>{intl.formatMessage(messages.confirm)}</Button>
</ActionRow>
</ModalDialog>
);
};

export default ResetExtensionsModal;
7 changes: 6 additions & 1 deletion src/dateExtensions/data/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base';
import { getApiBaseUrl } from '../../data/api';
import { DateExtensionsResponse } from '../types';
import { DateExtensionsResponse, ResetDueDateParams } from '../types';

export interface PaginationQueryKeys {
page: number,
Expand All @@ -16,3 +16,8 @@ export const getDateExtensions = async (
);
return camelCaseObject(data);
};

export const resetDateExtension = async (courseId: string, params: ResetDueDateParams) => {
const { data } = await getAuthenticatedHttpClient().post(`${getApiBaseUrl()}/courses/${courseId}/instructor/api/reset_due_date`, params);
return camelCaseObject(data);
};
16 changes: 14 additions & 2 deletions src/dateExtensions/data/apiHook.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { getDateExtensions, PaginationQueryKeys } from './api';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getDateExtensions, PaginationQueryKeys, resetDateExtension } from './api';
import { dateExtensionsQueryKeys } from './queryKeys';
import { ResetDueDateParams } from '../types';

export const useDateExtensions = (courseId: string, pagination: PaginationQueryKeys) => (
useQuery({
queryKey: dateExtensionsQueryKeys.byCoursePaginated(courseId, pagination),
queryFn: () => getDateExtensions(courseId, pagination),
})
);

export const useResetDateExtensionMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ courseId, params }: { courseId: string, params: ResetDueDateParams }) =>
resetDateExtension(courseId, params),
onSuccess: ({ courseId }) => {
queryClient.invalidateQueries({ queryKey: dateExtensionsQueryKeys.byCourse(courseId), exact: false });
},
});
};
Loading