diff --git a/src/Main.tsx b/src/Main.tsx index 703c380a..f7903492 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/components/SpecifyLearnerField/SpecifyLearnerField.tsx b/src/components/SpecifyLearnerField/SpecifyLearnerField.tsx new file mode 100644 index 00000000..a7db6dea --- /dev/null +++ b/src/components/SpecifyLearnerField/SpecifyLearnerField.tsx @@ -0,0 +1,19 @@ +import { Button, FormControl, FormGroup, FormLabel } from '@openedx/paragon'; + +interface SpecifyLearnerFieldProps { + onChange: (value: string) => void, +} + +const SpecifyLearnerField = ({ onChange }: SpecifyLearnerFieldProps) => { + return ( + + Specify Learner: +
+ onChange(e.target.value)} /> + +
+
+ ); +}; + +export default SpecifyLearnerField; diff --git a/src/dateExtensions/DateExtensionsPage.test.tsx b/src/dateExtensions/DateExtensionsPage.test.tsx new file mode 100644 index 00000000..99ed81df --- /dev/null +++ b/src/dateExtensions/DateExtensionsPage.test.tsx @@ -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: 'ed.byun@example.com', + 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 = () => ( + + + + } /> + + + + ); + + 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.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(); + 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); + }); + + it('opens reset modal when reset button is clicked', async () => { + render(); + 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(); + 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(); + 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(); + }); +}); diff --git a/src/dateExtensions/DateExtensionsPage.tsx b/src/dateExtensions/DateExtensionsPage.tsx new file mode 100644 index 00000000..778728ae --- /dev/null +++ b/src/dateExtensions/DateExtensionsPage.tsx @@ -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(null); + const [successMessage, setSuccessMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [isAddExtensionModalOpen, setIsAddExtensionModalOpen] = useState(false); + const [searchedLearner, setSearchedLearner] = useState(''); + const [gradedSubsectionFilter, setGradedSubsectionFilter] = useState(''); + + 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 ( + +

{intl.formatMessage(messages.dateExtensionsTitle)}

+
+
+ setSearchedLearner(e.target.value)} + placeholder={intl.formatMessage(messages.searchLearnerPlaceholder)} + trailingElement={} + value={searchedLearner} + /> + setGradedSubsectionFilter(e.target.value)} + value={gradedSubsectionFilter} + /> +
+ +
+ + setIsAddExtensionModalOpen(false)} + onSubmit={handleAddExtension} + /> + + {}} className="text-break"> + {successMessage} + + setErrorMessage('')}>{intl.formatMessage(messages.close)}}> + {errorMessage} + +
+ ); +}; + +export default DateExtensionsPage; diff --git a/src/dateExtensions/components/AddExtensionModal.tsx b/src/dateExtensions/components/AddExtensionModal.tsx new file mode 100644 index 00000000..b22644ae --- /dev/null +++ b/src/dateExtensions/components/AddExtensionModal.tsx @@ -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 ( + +
+ +

{title}

+
+ +
+

{intl.formatMessage(messages.extensionInstructions)}

+
+
+
+ {}} /> +
+
+ +
+
+
+
+

{intl.formatMessage(messages.defineExtension)}

+ + + {intl.formatMessage(messages.extensionDate)}: + +
+ + +
+
+ + + {intl.formatMessage(messages.reasonForExtension)}: + + + +
+
+
+ + + + + + +
+
+ ); +}; + +export default AddExtensionModal; diff --git a/src/dateExtensions/components/DateExtensionsList.test.tsx b/src/dateExtensions/components/DateExtensionsList.test.tsx new file mode 100644 index 00000000..29c349ae --- /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 00000000..3463e143 --- /dev/null +++ b/src/dateExtensions/components/DateExtensionsList.tsx @@ -0,0 +1,82 @@ +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, + searchedLearner?: string, + gradedSubsectionFilter?: string, +} + +interface DataTableFetchDataProps { + pageIndex: number, +} + +const DateExtensionsList = ({ + onResetExtensions = () => {}, + searchedLearner = '', + gradedSubsectionFilter = '', +}: 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, + search: searchedLearner, + gradedSubsection: gradedSubsectionFilter + }); + + 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/components/ResetExtensionsModal.test.tsx b/src/dateExtensions/components/ResetExtensionsModal.test.tsx new file mode 100644 index 00000000..a03397d0 --- /dev/null +++ b/src/dateExtensions/components/ResetExtensionsModal.test.tsx @@ -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( + + ); + + 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(); + }); +}); diff --git a/src/dateExtensions/components/ResetExtensionsModal.tsx b/src/dateExtensions/components/ResetExtensionsModal.tsx new file mode 100644 index 00000000..0fe4edd3 --- /dev/null +++ b/src/dateExtensions/components/ResetExtensionsModal.tsx @@ -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 ( + +

{title}

+

{message}

+ + + + +
+ ); +}; + +export default ResetExtensionsModal; diff --git a/src/dateExtensions/components/SelectGradedSubsection.tsx b/src/dateExtensions/components/SelectGradedSubsection.tsx new file mode 100644 index 00000000..23b8f0ce --- /dev/null +++ b/src/dateExtensions/components/SelectGradedSubsection.tsx @@ -0,0 +1,50 @@ +import { FormLabel, FormControl, FormGroup } from '@openedx/paragon'; +import { useGradedSubsections } from '../data/apiHook'; +import { useParams } from 'react-router'; + +interface SelectGradedSubsectionProps { + label?: string, + placeholder: string, + value?: string, + onChange: (event: React.ChangeEvent) => void, +} + +// Example API response used to test +// const options = [ +// { displayName: 'is an example', subsectionId: 'example' }, +// { displayName: 'another example', subsectionId: 'another' } +// ]; + +const SelectGradedSubsection = ({ label, placeholder, value, onChange }: SelectGradedSubsectionProps) => { + const { courseId = '' } = useParams<{ courseId: string }>(); + const { data = { items: [] } } = useGradedSubsections(courseId); + const selectOptions = [{ displayName: placeholder, subsectionId: '' }, ...data.items]; + + const handleChange = (event: React.ChangeEvent) => { + onChange(event); + }; + + return ( + + {label && {label}} + + { + selectOptions.map((option) => ( + + )) + } + + + ); +}; + +export default SelectGradedSubsection; diff --git a/src/dateExtensions/data/api.ts b/src/dateExtensions/data/api.ts new file mode 100644 index 00000000..88ef39f1 --- /dev/null +++ b/src/dateExtensions/data/api.ts @@ -0,0 +1,62 @@ +import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base'; +import { getApiBaseUrl } from '../../data/api'; +import { DateExtensionsResponse } from '../types'; + +export interface PaginationQueryKeys { + page: number, + pageSize: number, +} + +export interface DateExtensionQueryParams extends PaginationQueryKeys { + search?: string, + gradedSubsection?: string, +} + +export const getDateExtensions = async ( + courseId: string, + params: DateExtensionQueryParams +): Promise => { + const queryParams = new URLSearchParams({ + page: params.page.toString(), + page_size: params.pageSize.toString(), + }); + + // Add optional search parameter + if (params.search) { + queryParams.append('search', params.search); + } + + // Add optional graded subsection filter + if (params.gradedSubsection) { + queryParams.append('graded_subsection', params.gradedSubsection); + } + + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/unit_extensions/?${queryParams.toString()}` + ); + return camelCaseObject(data); +}; + +export const resetDateExtension = async (courseId, userId) => { + const { data } = await getAuthenticatedHttpClient().post(`${getApiBaseUrl()}/api/instructor/v1/courses/${courseId}/date-extensions/${userId}/reset`); + return camelCaseObject(data); +}; + +interface AddDateExtensionParams { + email_or_username: string, + block_id: string, + due_datetime: string, + reason: string, +} + +export const addDateExtension = async (courseId, extensionData: AddDateExtensionParams) => { + const { data } = await getAuthenticatedHttpClient().post(`${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/change_due_date`, extensionData); + return camelCaseObject(data); +}; + +export const getGradedSubsections = async (courseId: string) => { + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/graded_subsections/` + ); + return camelCaseObject(data); +}; diff --git a/src/dateExtensions/data/apiHook.ts b/src/dateExtensions/data/apiHook.ts new file mode 100644 index 00000000..f682d995 --- /dev/null +++ b/src/dateExtensions/data/apiHook.ts @@ -0,0 +1,40 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { getDateExtensions, resetDateExtension, addDateExtension, getGradedSubsections, DateExtensionQueryParams } from './api'; +import { dateExtensionsQueryKeys, gradedSubsectionsQueryKeys } from './queryKeys'; + +export const useDateExtensions = (courseId: string, params: DateExtensionQueryParams) => ( + useQuery({ + queryKey: dateExtensionsQueryKeys.byCoursePaginated(courseId, params), + queryFn: () => getDateExtensions(courseId, params), + enabled: !!courseId, // Only run when courseId is available + }) +); + +export const useResetDateExtensionMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ courseId, userId }: { courseId: string, userId: number }) => + resetDateExtension(courseId, userId), + onSuccess: ({ courseId }) => { + queryClient.invalidateQueries({ queryKey: dateExtensionsQueryKeys.byCourse(courseId) }); + }, + }); +}; + +export const useAddDateExtensionMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ courseId, extensionData }: { courseId: string, extensionData: any }) => + addDateExtension(courseId, extensionData), + onSuccess: ({ courseId }) => { + queryClient.invalidateQueries({ queryKey: dateExtensionsQueryKeys.byCourse(courseId) }); + }, + }); +}; + +export const useGradedSubsections = (courseId: string) => ( + useQuery({ + queryKey: gradedSubsectionsQueryKeys.byCourse(courseId), + queryFn: () => getGradedSubsections(courseId), + }) +); diff --git a/src/dateExtensions/data/queryKeys.ts b/src/dateExtensions/data/queryKeys.ts new file mode 100644 index 00000000..1348c84c --- /dev/null +++ b/src/dateExtensions/data/queryKeys.ts @@ -0,0 +1,13 @@ +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, +}; + +export const gradedSubsectionsQueryKeys = { + all: [appId, 'gradedSubsections'] as const, + byCourse: (courseId: string) => [...gradedSubsectionsQueryKeys.all, courseId] as const, +}; diff --git a/src/dateExtensions/messages.ts b/src/dateExtensions/messages.ts new file mode 100644 index 00000000..05b64ad9 --- /dev/null +++ b/src/dateExtensions/messages.ts @@ -0,0 +1,116 @@ +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', + }, + resetConfirmationHeader: { + id: 'instruct.dateExtensions.page.resetModal.confirmationHeader', + defaultMessage: 'Reset extensions for {username}?', + description: 'Header for the reset confirmation modal', + }, + resetConfirmationMessage: { + id: 'instruct.dateExtensions.page.resetModal.confirmationMessage', + defaultMessage: 'Resetting a problem\'s due date rescinds a due date extension for a student on a particular subsection. This will revert the due date for the student back to the problem\'s original due date.', + description: 'Confirmation message for resetting extensions in the reset modal', + }, + cancel: { + id: 'instruct.dateExtensions.page.resetModal.cancel', + defaultMessage: 'Cancel', + description: 'Label for the cancel button in the reset modal', + }, + confirm: { + id: 'instruct.dateExtensions.page.resetModal.confirm', + defaultMessage: 'Reset Due Date for Student', + description: 'Label for the confirm button in the reset modal', + }, + close: { + id: 'instruct.dateExtensions.page.resetModal.close', + defaultMessage: 'Close', + description: 'Label for the close button in the reset modal', + }, + addIndividualDueDateExtension: { + id: 'instruct.dateExtensions.page.addIndividualDueDateExtensionModal.title', + defaultMessage: 'Add Individual Due Date Extension', + description: 'Title for the add individual due date extension modal', + }, + addExtension: { + id: 'instruct.dateExtensions.page.addIndividualDueDateExtensionModal.addExtension', + defaultMessage: 'Add Extension', + description: 'Label for the add extension button', + }, + extensionInstructions: { + id: 'instruct.dateExtensions.page.addIndividualDueDateExtensionModal.extensionInstructions', + defaultMessage: 'To grant an extension, select a student, graded subsection, and define the extension due date and time.', + description: 'Instructions for adding an individual due date extension', + }, + defineExtension: { + id: 'instruct.dateExtensions.page.addIndividualDueDateExtensionModal.defineExtension', + defaultMessage: 'Define Extension', + description: 'Label for the define extension section', + }, + extensionDate: { + id: 'instruct.dateExtensions.page.addIndividualDueDateExtensionModal.extensionDate', + defaultMessage: 'Extension Date', + description: 'Label for the extension date field', + }, + reasonForExtension: { + id: 'instruct.dateExtensions.page.addIndividualDueDateExtensionModal.reasonForExtension', + defaultMessage: 'Reason for Extension', + description: 'Label for the reason for extension field', + }, + selectGradedSubsection: { + id: 'instruct.dateExtensions.page.addIndividualDueDateExtensionModal.selectGradedSubsection', + defaultMessage: 'Select Graded Subsection', + description: 'Label for the select graded subsection field', + }, + allGradedSubsections: { + id: 'instruct.dateExtensions.page.filters.allGradedSubsections', + defaultMessage: 'All Graded Subsections', + description: 'Label for the all graded subsections option in filters', + }, + searchLearnerPlaceholder: { + id: 'instruct.dateExtensions.page.filters.searchLearnerPlaceholder', + defaultMessage: 'Search for a Learner', + description: 'Placeholder text for the search learner input field', + } +}); + +export default messages; diff --git a/src/dateExtensions/types.ts b/src/dateExtensions/types.ts new file mode 100644 index 00000000..32550e22 --- /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/main.scss b/src/main.scss index defee907..c13ac3dc 100644 --- a/src/main.scss +++ b/src/main.scss @@ -1 +1,6 @@ @use "@openedx/frontend-base/shell/app.scss"; + +.toast-container { + left: unset; + right: 1.25rem; +} diff --git a/src/routes.tsx b/src/routes.tsx index 96c1ac98..18d74efd 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: