diff --git a/src/Main.tsx b/src/Main.tsx
index 703c380..f790349 100644
--- a/src/Main.tsx
+++ b/src/Main.tsx
@@ -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();
diff --git a/src/dateExtensions/DateExtensionsPage.test.tsx b/src/dateExtensions/DateExtensionsPage.test.tsx
new file mode 100644
index 0000000..0443282
--- /dev/null
+++ b/src/dateExtensions/DateExtensionsPage.test.tsx
@@ -0,0 +1,70 @@
+import { render, screen } from '@testing-library/react';
+import { IntlProvider } from '@openedx/frontend-base';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import DateExtensionsPage from './DateExtensionsPage';
+import { useDateExtensions } from './data/apiHook';
+
+jest.mock('./data/apiHook', () => ({
+ useDateExtensions: jest.fn(),
+}));
+
+const mockDateExtensions = [
+ {
+ id: 1,
+ username: 'edByun',
+ fullname: 'Ed Byun',
+ email: 'ed.byun@example.com',
+ graded_subsection: 'Three body diagrams',
+ extended_due_date: '2026-07-15'
+ },
+];
+
+describe('DateExtensionsPage', () => {
+ beforeEach(() => {
+ (useDateExtensions as jest.Mock).mockReturnValue({
+ data: { count: mockDateExtensions.length, results: mockDateExtensions },
+ isLoading: false,
+ });
+ });
+
+ const RenderWithRouter = () => (
+
+
+
+ } />
+
+
+
+ );
+
+ it('renders page title', () => {
+ render();
+ expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
+ });
+
+ it('renders add extension button', () => {
+ render();
+ expect(screen.getByRole('button', { name: /add individual extension/i })).toBeInTheDocument();
+ });
+
+ it('renders date extensions list', () => {
+ render();
+ expect(screen.getByText('Ed Byun')).toBeInTheDocument();
+ expect(screen.getByText('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();
+ expect(screen.getByRole('status')).toBeInTheDocument();
+ });
+
+ it('renders reset link for each row', () => {
+ render();
+ const resetLinks = screen.getAllByRole('button', { name: 'Reset Extensions' });
+ expect(resetLinks).toHaveLength(mockDateExtensions.length);
+ });
+});
diff --git a/src/dateExtensions/DateExtensionsPage.tsx b/src/dateExtensions/DateExtensionsPage.tsx
new file mode 100644
index 0000000..0b7ecb8
--- /dev/null
+++ b/src/dateExtensions/DateExtensionsPage.tsx
@@ -0,0 +1,29 @@
+import { useIntl } from '@openedx/frontend-base';
+import messages from './messages';
+import DateExtensionsList from './components/DateExtensionsList';
+import { Button, Container } from '@openedx/paragon';
+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';
+
+const DateExtensionsPage = () => {
+ const intl = useIntl();
+
+ const handleResetExtensions = (user: LearnerDateExtension) => {
+ // Implementation for resetting extensions will go here
+ console.log(user);
+ };
+
+ return (
+
+ {intl.formatMessage(messages.dateExtensionsTitle)}
+
+
filters
+
+
+
+
+ );
+};
+
+export default DateExtensionsPage;
diff --git a/src/dateExtensions/components/DateExtensionsList.test.tsx b/src/dateExtensions/components/DateExtensionsList.test.tsx
new file mode 100644
index 0000000..29c349a
--- /dev/null
+++ b/src/dateExtensions/components/DateExtensionsList.test.tsx
@@ -0,0 +1,56 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import DateExtensionsList, { DateExtensionListProps } from './DateExtensionsList';
+import { renderWithIntl } from '../../testUtils';
+import { useDateExtensions } from '../data/apiHook';
+
+const mockData = [
+ {
+ id: 1,
+ username: 'test_user',
+ fullname: 'Test User',
+ email: 'test@example.com',
+ graded_subsection: 'Test Section',
+ extended_due_date: '2024-01-01'
+ }
+];
+
+jest.mock('../data/apiHook', () => ({
+ useDateExtensions: jest.fn(),
+}));
+
+const mockResetExtensions = jest.fn();
+
+describe('DateExtensionsList', () => {
+ const renderComponent = (props: DateExtensionListProps) => renderWithIntl(
+
+ );
+
+ it('renders loading state on the table', () => {
+ (useDateExtensions as jest.Mock).mockReturnValue({ isLoading: true, data: { count: 0, results: [] } });
+ renderComponent({});
+ expect(screen.getByRole('status')).toBeInTheDocument();
+ });
+
+ it('renders table with data', async () => {
+ (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();
+ const resetExtensions = screen.getByRole('button', { name: /reset extensions/i });
+ expect(resetExtensions).toBeInTheDocument();
+ await user.click(resetExtensions);
+ expect(mockResetExtensions).toHaveBeenCalledWith(mockData[0]);
+ });
+
+ 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.getByText('No results found')).toBeInTheDocument();
+ });
+});
diff --git a/src/dateExtensions/components/DateExtensionsList.tsx b/src/dateExtensions/components/DateExtensionsList.tsx
new file mode 100644
index 0000000..7b089e4
--- /dev/null
+++ b/src/dateExtensions/components/DateExtensionsList.tsx
@@ -0,0 +1,76 @@
+import { useIntl } from '@openedx/frontend-base';
+import { Button, DataTable } from '@openedx/paragon';
+import messages from '../messages';
+import { LearnerDateExtension } from '../types';
+import { useDateExtensions } from '../data/apiHook';
+import { useParams } from 'react-router-dom';
+import { useState } from 'react';
+
+// For testing purposes, will be deleted once backend is ready
+// const mockDateExtensions = [
+// { id: 1, username: 'edByun', fullname: 'Ed Byun', email: 'ed.byun@example.com', graded_subsection: 'Three body diagrams', extended_due_date: '2026-07-15' },
+// { id: 2, username: 'dianaSalas', fullname: 'Diana Villalvazo', email: 'diana.villalvazo@example.com', graded_subsection: 'Three body diagrams', extended_due_date: '2026-07-15' },
+// ];
+
+const DATE_EXTENSIONS_PAGE_SIZE = 25;
+
+export interface DateExtensionListProps {
+ onResetExtensions?: (user: LearnerDateExtension) => void,
+}
+
+interface DataTableFetchDataProps {
+ pageIndex: number,
+}
+
+const DateExtensionsList = ({
+ onResetExtensions = () => {},
+}: DateExtensionListProps) => {
+ const intl = useIntl();
+ const { courseId } = useParams();
+ const [page, setPage] = useState(0);
+ const { data = { count: 0, results: [] }, isLoading } = useDateExtensions(courseId ?? '', {
+ page,
+ pageSize: DATE_EXTENSIONS_PAGE_SIZE
+ });
+
+ const pageCount = Math.ceil(data.count / DATE_EXTENSIONS_PAGE_SIZE);
+
+ const tableColumns = [
+ { accessor: 'username', Header: intl.formatMessage(messages.username) },
+ { accessor: 'fullname', Header: intl.formatMessage(messages.fullname) },
+ { accessor: 'email', Header: intl.formatMessage(messages.email) },
+ { accessor: 'graded_subsection', Header: intl.formatMessage(messages.gradedSubsection) },
+ { accessor: 'extended_due_date', Header: intl.formatMessage(messages.extendedDueDate) },
+ { accessor: 'reset', Header: intl.formatMessage(messages.reset) },
+ ];
+
+ const tableData = data.results.map(item => ({
+ ...item,
+ reset: ,
+ }));
+
+ const handleFetchData = (data: DataTableFetchDataProps) => {
+ setPage(data.pageIndex);
+ };
+
+ return (
+
+ );
+};
+
+export default DateExtensionsList;
diff --git a/src/dateExtensions/data/api.ts b/src/dateExtensions/data/api.ts
new file mode 100644
index 0000000..3623ed6
--- /dev/null
+++ b/src/dateExtensions/data/api.ts
@@ -0,0 +1,18 @@
+import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base';
+import { getApiBaseUrl } from '../../data/api';
+import { DateExtensionsResponse } from '../types';
+
+export interface PaginationQueryKeys {
+ page: number,
+ pageSize: number,
+}
+
+export const getDateExtensions = async (
+ courseId: string,
+ pagination: PaginationQueryKeys
+): Promise => {
+ const { data } = await getAuthenticatedHttpClient().get(
+ `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/unit_extensions/?page=${pagination.page}&page_size=${pagination.pageSize}`
+ );
+ return camelCaseObject(data);
+};
diff --git a/src/dateExtensions/data/apiHook.ts b/src/dateExtensions/data/apiHook.ts
new file mode 100644
index 0000000..e0a42a0
--- /dev/null
+++ b/src/dateExtensions/data/apiHook.ts
@@ -0,0 +1,10 @@
+import { useQuery } from '@tanstack/react-query';
+import { getDateExtensions, PaginationQueryKeys } from './api';
+import { dateExtensionsQueryKeys } from './queryKeys';
+
+export const useDateExtensions = (courseId: string, pagination: PaginationQueryKeys) => (
+ useQuery({
+ queryKey: dateExtensionsQueryKeys.byCoursePaginated(courseId, pagination),
+ queryFn: () => getDateExtensions(courseId, pagination),
+ })
+);
diff --git a/src/dateExtensions/data/queryKeys.ts b/src/dateExtensions/data/queryKeys.ts
new file mode 100644
index 0000000..9bfd984
--- /dev/null
+++ b/src/dateExtensions/data/queryKeys.ts
@@ -0,0 +1,8 @@
+import { appId } from '../../constants';
+import { PaginationQueryKeys } from './api';
+
+export const dateExtensionsQueryKeys = {
+ all: [appId, 'dateExtensions'] as const,
+ byCourse: (courseId: string) => [...dateExtensionsQueryKeys.all, courseId] as const,
+ byCoursePaginated: (courseId: string, pagination: PaginationQueryKeys) => [...dateExtensionsQueryKeys.byCourse(courseId), pagination.page] as const,
+};
diff --git a/src/dateExtensions/messages.ts b/src/dateExtensions/messages.ts
new file mode 100644
index 0000000..5be9f42
--- /dev/null
+++ b/src/dateExtensions/messages.ts
@@ -0,0 +1,46 @@
+import { defineMessages } from '@openedx/frontend-base';
+
+const messages = defineMessages({
+ dateExtensionsTitle: {
+ id: 'instruct.dateExtensions.page.title',
+ defaultMessage: 'Viewing Granted Extensions',
+ description: 'Title for date extensions page',
+ },
+ addIndividualExtension: {
+ id: 'instruct.dateExtensions.page.addIndividualExtension',
+ defaultMessage: 'Add Individual Extension',
+ description: 'Button text for adding an individual date extension',
+ },
+ username: {
+ id: 'instruct.dateExtensions.page.tableHeader.username',
+ defaultMessage: 'User Name',
+ description: 'Label for the user name column in the date extensions table',
+ },
+ fullname: {
+ id: 'instruct.dateExtensions.page.tableHeader.fullname',
+ defaultMessage: 'Full Name',
+ description: 'Label for the full name column in the date extensions table',
+ },
+ email: {
+ id: 'instruct.dateExtensions.page.tableHeader.email',
+ defaultMessage: 'Email',
+ description: 'Label for the email column in the date extensions table',
+ },
+ gradedSubsection: {
+ id: 'instruct.dateExtensions.page.tableHeader.gradedSubsection',
+ defaultMessage: 'Graded Subsection',
+ description: 'Label for the graded subsection column in the date extensions table',
+ },
+ extendedDueDate: {
+ id: 'instruct.dateExtensions.page.tableHeader.extendedDueDate',
+ defaultMessage: 'Extended Due Date',
+ description: 'Label for the extended due date column in the date extensions table',
+ },
+ reset: {
+ id: 'instruct.dateExtensions.page.tableHeader.reset',
+ defaultMessage: 'Reset',
+ description: 'Label for the reset column in the date extensions table',
+ },
+});
+
+export default messages;
diff --git a/src/dateExtensions/types.ts b/src/dateExtensions/types.ts
new file mode 100644
index 0000000..32550e2
--- /dev/null
+++ b/src/dateExtensions/types.ts
@@ -0,0 +1,15 @@
+export interface LearnerDateExtension {
+ id: number,
+ username: string,
+ fullname: string,
+ email: string,
+ graded_subsection: string,
+ extended_due_date: string,
+}
+
+export interface DateExtensionsResponse {
+ count: number,
+ next: string | null,
+ previous: string | null,
+ results: LearnerDateExtension[],
+}
diff --git a/src/routes.tsx b/src/routes.tsx
index 96c1ac9..18d74ef 100644
--- a/src/routes.tsx
+++ b/src/routes.tsx
@@ -1,5 +1,6 @@
import CohortsPage from './cohorts/CohortsPage';
import CourseInfoPage from './courseInfo/CourseInfoPage';
+import DateExtensionsPage from './dateExtensions/DateExtensionsPage';
import Main from './Main';
const routes = [
@@ -23,10 +24,10 @@ const routes = [
path: 'cohorts',
element:
},
- // {
- // path: 'extensions',
- // element:
- // },
+ {
+ path: 'date_extensions',
+ element:
+ },
// {
// path: 'student_admin',
// element: