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: