From 5bc754c8e69564ee5731d956eff6f37bfb602653 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Mon, 3 Nov 2025 18:34:31 -0600 Subject: [PATCH 01/17] feat: date extensions data table --- src/dateExtensions/DateExtensionsPage.tsx | 20 ++++++++ .../components/DateExtensionsList.tsx | 29 ++++++++++++ src/dateExtensions/messages.ts | 46 +++++++++++++++++++ src/routes.tsx | 9 ++-- 4 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 src/dateExtensions/DateExtensionsPage.tsx create mode 100644 src/dateExtensions/components/DateExtensionsList.tsx create mode 100644 src/dateExtensions/messages.ts diff --git a/src/dateExtensions/DateExtensionsPage.tsx b/src/dateExtensions/DateExtensionsPage.tsx new file mode 100644 index 00000000..68487697 --- /dev/null +++ b/src/dateExtensions/DateExtensionsPage.tsx @@ -0,0 +1,20 @@ +import { useIntl } from '@openedx/frontend-base'; +import messages from './messages'; +import DateExtensionsList from './components/DateExtensionsList'; +import { Button } from '@openedx/paragon'; + +const DateExtensionsPage = () => { + const intl = useIntl(); + return ( +
+

{intl.formatMessage(messages.dateExtensionsTitle)}

+
+

filters

+ +
+ +
+ ); +}; + +export default DateExtensionsPage; diff --git a/src/dateExtensions/components/DateExtensionsList.tsx b/src/dateExtensions/components/DateExtensionsList.tsx new file mode 100644 index 00000000..ae3f605d --- /dev/null +++ b/src/dateExtensions/components/DateExtensionsList.tsx @@ -0,0 +1,29 @@ +import { useIntl } from '@openedx/frontend-base'; +import { DataTable } from '@openedx/paragon'; +import messages from '../messages'; + +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', reset: Reset Extensions }, +]; + +const DATE_EXTENSIONS_PAGE_SIZE = 25; + +const DateExtensionsList = () => { + const intl = useIntl(); + + 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.graded_subsection) }, + { accessor: 'extended_due_date', Header: intl.formatMessage(messages.extended_due_date) }, + { accessor: 'reset', Header: intl.formatMessage(messages.reset) }, + ]; + + const totalItemCount = 25; + return ( + + ); +}; + +export default DateExtensionsList; diff --git a/src/dateExtensions/messages.ts b/src/dateExtensions/messages.ts new file mode 100644 index 00000000..f92a6317 --- /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', + }, + graded_subsection: { + id: 'instruct.dateExtensions.page.tableHeader.graded_subsection', + defaultMessage: 'Graded Subsection', + description: 'Label for the graded subsection column in the date extensions table', + }, + extended_due_date: { + id: 'instruct.dateExtensions.page.tableHeader.extended_due_date', + 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/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: From a0eca6264931916db4e3361e1721faf00f4f1c76 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Tue, 4 Nov 2025 17:04:48 -0600 Subject: [PATCH 02/17] chore: add provider and api call --- src/dateExtensions/DateExtensionsPage.tsx | 11 ++++++- .../components/DateExtensionsList.tsx | 29 +++++++++++++++---- src/dateExtensions/data/api.ts | 7 +++++ src/dateExtensions/data/apiHook.ts | 10 +++++++ src/dateExtensions/data/queryKeys.ts | 6 ++++ src/routes.tsx | 2 +- 6 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 src/dateExtensions/data/api.ts create mode 100644 src/dateExtensions/data/apiHook.ts create mode 100644 src/dateExtensions/data/queryKeys.ts diff --git a/src/dateExtensions/DateExtensionsPage.tsx b/src/dateExtensions/DateExtensionsPage.tsx index 68487697..fa57f0ac 100644 --- a/src/dateExtensions/DateExtensionsPage.tsx +++ b/src/dateExtensions/DateExtensionsPage.tsx @@ -2,9 +2,18 @@ import { useIntl } from '@openedx/frontend-base'; import messages from './messages'; import DateExtensionsList from './components/DateExtensionsList'; import { Button } from '@openedx/paragon'; +import { useDateExtensions } from './data/apiHook'; +import { useParams } from 'react-router-dom'; + +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 DateExtensionsPage = () => { const intl = useIntl(); + const { courseId } = useParams(); + const { data, isLoading } = useDateExtensions(courseId ?? ''); + return (

{intl.formatMessage(messages.dateExtensionsTitle)}

@@ -12,7 +21,7 @@ const DateExtensionsPage = () => {

filters

- + ); }; diff --git a/src/dateExtensions/components/DateExtensionsList.tsx b/src/dateExtensions/components/DateExtensionsList.tsx index ae3f605d..b5534efa 100644 --- a/src/dateExtensions/components/DateExtensionsList.tsx +++ b/src/dateExtensions/components/DateExtensionsList.tsx @@ -2,13 +2,24 @@ import { useIntl } from '@openedx/frontend-base'; import { DataTable } from '@openedx/paragon'; import messages from '../messages'; -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', reset: Reset Extensions }, -]; - const DATE_EXTENSIONS_PAGE_SIZE = 25; -const DateExtensionsList = () => { +interface DateExtensionListProps { + data: { + id: number, + username: string, + fullname: string, + email: string, + graded_subsection: string, + extended_due_date: string, + }[], + isLoading: boolean, +} + +const DateExtensionsList = ({ + data = [], + isLoading = false, +}: DateExtensionListProps) => { const intl = useIntl(); const tableColumns = [ @@ -21,8 +32,14 @@ const DateExtensionsList = () => { ]; const totalItemCount = 25; + + const tableData = data.map(item => ({ + ...item, + reset: Reset Extensions, + })); + return ( - + ); }; diff --git a/src/dateExtensions/data/api.ts b/src/dateExtensions/data/api.ts new file mode 100644 index 00000000..89090753 --- /dev/null +++ b/src/dateExtensions/data/api.ts @@ -0,0 +1,7 @@ +import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base'; +import { getApiBaseUrl } from '../../data/api'; + +export const getDateExtensions = async (courseId) => { + const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/date-extensions`); + return camelCaseObject(data); +}; diff --git a/src/dateExtensions/data/apiHook.ts b/src/dateExtensions/data/apiHook.ts new file mode 100644 index 00000000..b27a2f46 --- /dev/null +++ b/src/dateExtensions/data/apiHook.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; +import { getDateExtensions } from './api'; +import { dateExtensionsQueryKeys } from './queryKeys'; + +export const useDateExtensions = (courseId: string) => ( + useQuery({ + queryKey: dateExtensionsQueryKeys.byCourse(courseId), + queryFn: () => getDateExtensions(courseId), + }) +); diff --git a/src/dateExtensions/data/queryKeys.ts b/src/dateExtensions/data/queryKeys.ts new file mode 100644 index 00000000..752aec89 --- /dev/null +++ b/src/dateExtensions/data/queryKeys.ts @@ -0,0 +1,6 @@ +import { appId } from '../../constants'; + +export const dateExtensionsQueryKeys = { + all: [appId, 'dateExtensions'] as const, + byCourse: (courseId: string) => [appId, 'dateExtensions', courseId] as const, +}; diff --git a/src/routes.tsx b/src/routes.tsx index 18d74efd..f955838f 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -25,7 +25,7 @@ const routes = [ element: }, { - path: 'date_extensions', + path: '/:courseId/date_extensions', element: }, // { From 45e562bf4271a2288cb8eb6792e8028ccd7b5216 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Wed, 5 Nov 2025 17:11:59 -0600 Subject: [PATCH 03/17] style: adding margins --- src/dateExtensions/DateExtensionsPage.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/dateExtensions/DateExtensionsPage.tsx b/src/dateExtensions/DateExtensionsPage.tsx index fa57f0ac..fe3cb01c 100644 --- a/src/dateExtensions/DateExtensionsPage.tsx +++ b/src/dateExtensions/DateExtensionsPage.tsx @@ -1,7 +1,7 @@ import { useIntl } from '@openedx/frontend-base'; import messages from './messages'; import DateExtensionsList from './components/DateExtensionsList'; -import { Button } from '@openedx/paragon'; +import { Button, Container } from '@openedx/paragon'; import { useDateExtensions } from './data/apiHook'; import { useParams } from 'react-router-dom'; @@ -15,14 +15,14 @@ const DateExtensionsPage = () => { const { data, isLoading } = useDateExtensions(courseId ?? ''); return ( -
+

{intl.formatMessage(messages.dateExtensionsTitle)}

-
+

filters

-
+
); }; From f6348e9890e2513c80feed5621492fe03b8ed73b Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Thu, 6 Nov 2025 15:37:53 -0600 Subject: [PATCH 04/17] test: add unit tests --- .../DateExtensionsPage.test.tsx | 64 +++++++++++++++++++ .../components/DateExtensionsList.test.tsx | 46 +++++++++++++ .../components/DateExtensionsList.tsx | 4 +- 3 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 src/dateExtensions/DateExtensionsPage.test.tsx create mode 100644 src/dateExtensions/components/DateExtensionsList.test.tsx diff --git a/src/dateExtensions/DateExtensionsPage.test.tsx b/src/dateExtensions/DateExtensionsPage.test.tsx new file mode 100644 index 00000000..082c0ae1 --- /dev/null +++ b/src/dateExtensions/DateExtensionsPage.test.tsx @@ -0,0 +1,64 @@ +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: 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: null, + isLoading: true, + }); + render(); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); +}); diff --git a/src/dateExtensions/components/DateExtensionsList.test.tsx b/src/dateExtensions/components/DateExtensionsList.test.tsx new file mode 100644 index 00000000..499f3403 --- /dev/null +++ b/src/dateExtensions/components/DateExtensionsList.test.tsx @@ -0,0 +1,46 @@ +import { screen } from '@testing-library/react'; +import DateExtensionsList, { DateExtensionListProps } from './DateExtensionsList'; +import { renderWithIntl } from '../../testUtils'; + +const mockData = [ + { + id: 1, + username: 'test_user', + fullname: 'Test User', + email: 'test@example.com', + graded_subsection: 'Test Section', + extended_due_date: '2024-01-01' + } +]; + +describe('DateExtensionsList', () => { + const renderComponent = (props: DateExtensionListProps) => renderWithIntl( + + ); + + it('renders loading state on the table', () => { + renderComponent({ data: [], isLoading: true }); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('renders table with data', () => { + renderComponent({ data: mockData }); + 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(); + }); + + it('renders reset link for each row', () => { + renderComponent({ data: mockData }); + const resetLinks = screen.getAllByRole('link', { name: 'Reset Extensions' }); + expect(resetLinks).toHaveLength(mockData.length); + }); + + it('renders empty table when no data provided', () => { + renderComponent({ data: [] }); + 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 index b5534efa..07ec3464 100644 --- a/src/dateExtensions/components/DateExtensionsList.tsx +++ b/src/dateExtensions/components/DateExtensionsList.tsx @@ -4,7 +4,7 @@ import messages from '../messages'; const DATE_EXTENSIONS_PAGE_SIZE = 25; -interface DateExtensionListProps { +export interface DateExtensionListProps { data: { id: number, username: string, @@ -13,7 +13,7 @@ interface DateExtensionListProps { graded_subsection: string, extended_due_date: string, }[], - isLoading: boolean, + isLoading?: boolean, } const DateExtensionsList = ({ From cc024661ef6937f562110cfc86433afc3b714541 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Mon, 10 Nov 2025 15:40:18 -0600 Subject: [PATCH 05/17] refactor: small changes --- .../DateExtensionsPage.test.tsx | 8 ++++- src/dateExtensions/DateExtensionsPage.tsx | 30 +++++++++++++++---- .../components/DateExtensionsList.test.tsx | 6 ---- .../components/DateExtensionsList.tsx | 19 ++---------- 4 files changed, 35 insertions(+), 28 deletions(-) diff --git a/src/dateExtensions/DateExtensionsPage.test.tsx b/src/dateExtensions/DateExtensionsPage.test.tsx index 082c0ae1..013ce4cf 100644 --- a/src/dateExtensions/DateExtensionsPage.test.tsx +++ b/src/dateExtensions/DateExtensionsPage.test.tsx @@ -55,10 +55,16 @@ describe('DateExtensionsPage', () => { it('shows loading state on table when fetching data', () => { (useDateExtensions as jest.Mock).mockReturnValue({ - data: null, + data: [], 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 index fe3cb01c..b11a1a14 100644 --- a/src/dateExtensions/DateExtensionsPage.tsx +++ b/src/dateExtensions/DateExtensionsPage.tsx @@ -5,14 +5,34 @@ import { Button, Container } from '@openedx/paragon'; import { useDateExtensions } from './data/apiHook'; import { useParams } from 'react-router-dom'; -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' }, -]; +// 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' }, +// ]; + +export interface User { + id: number, + username: string, + fullname: string, + email: string, + graded_subsection: string, + extended_due_date: string, +} const DateExtensionsPage = () => { const intl = useIntl(); const { courseId } = useParams(); - const { data, isLoading } = useDateExtensions(courseId ?? ''); + const { data = [], isLoading } = useDateExtensions(courseId ?? ''); + + const handleResetExtensions = (user: User) => { + // Implementation for resetting extensions will go here + console.log(user); + }; + + const tableData = data.map(item => ({ + ...item, + reset: , + })); return ( @@ -21,7 +41,7 @@ const DateExtensionsPage = () => {

filters

- + ); }; diff --git a/src/dateExtensions/components/DateExtensionsList.test.tsx b/src/dateExtensions/components/DateExtensionsList.test.tsx index 499f3403..bd2739b8 100644 --- a/src/dateExtensions/components/DateExtensionsList.test.tsx +++ b/src/dateExtensions/components/DateExtensionsList.test.tsx @@ -32,12 +32,6 @@ describe('DateExtensionsList', () => { expect(screen.getByText('2024-01-01')).toBeInTheDocument(); }); - it('renders reset link for each row', () => { - renderComponent({ data: mockData }); - const resetLinks = screen.getAllByRole('link', { name: 'Reset Extensions' }); - expect(resetLinks).toHaveLength(mockData.length); - }); - it('renders empty table when no data provided', () => { renderComponent({ data: [] }); expect(screen.queryByText('test_user')).not.toBeInTheDocument(); diff --git a/src/dateExtensions/components/DateExtensionsList.tsx b/src/dateExtensions/components/DateExtensionsList.tsx index 07ec3464..a38da72a 100644 --- a/src/dateExtensions/components/DateExtensionsList.tsx +++ b/src/dateExtensions/components/DateExtensionsList.tsx @@ -1,18 +1,12 @@ import { useIntl } from '@openedx/frontend-base'; import { DataTable } from '@openedx/paragon'; import messages from '../messages'; +import { User } from '../DateExtensionsPage'; const DATE_EXTENSIONS_PAGE_SIZE = 25; export interface DateExtensionListProps { - data: { - id: number, - username: string, - fullname: string, - email: string, - graded_subsection: string, - extended_due_date: string, - }[], + data: User[], isLoading?: boolean, } @@ -33,14 +27,7 @@ const DateExtensionsList = ({ const totalItemCount = 25; - const tableData = data.map(item => ({ - ...item, - reset: Reset Extensions, - })); - - return ( - - ); + return ; }; export default DateExtensionsList; From b1805c1d358611a27256a57ef60970ec1d48ff31 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Thu, 13 Nov 2025 09:56:16 -0600 Subject: [PATCH 06/17] fix: update endpoints --- src/Main.tsx | 4 +--- src/dateExtensions/data/api.ts | 2 +- src/routes.tsx | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) 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/dateExtensions/data/api.ts b/src/dateExtensions/data/api.ts index 89090753..96bfe6e8 100644 --- a/src/dateExtensions/data/api.ts +++ b/src/dateExtensions/data/api.ts @@ -2,6 +2,6 @@ import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-b import { getApiBaseUrl } from '../../data/api'; export const getDateExtensions = async (courseId) => { - const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/date-extensions`); + const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/unit_extensions`); return camelCaseObject(data); }; diff --git a/src/routes.tsx b/src/routes.tsx index f955838f..986c0a1b 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -25,7 +25,7 @@ const routes = [ element: }, { - path: '/:courseId/date_extensions', + path: '/date_extensions', element: }, // { From 778a5243d3cbd5cc2c8e79709a9c22f0ca270fb5 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Thu, 13 Nov 2025 12:56:27 -0600 Subject: [PATCH 07/17] feat: implementing pagination --- package.json | 2 +- .../DateExtensionsPage.test.tsx | 4 +- src/dateExtensions/DateExtensionsPage.tsx | 28 ++------- .../components/DateExtensionsList.test.tsx | 24 ++++++-- .../components/DateExtensionsList.tsx | 61 ++++++++++++++++--- src/dateExtensions/data/api.ts | 15 ++++- src/dateExtensions/data/apiHook.ts | 6 +- src/dateExtensions/types.ts | 15 +++++ src/routes.tsx | 2 +- 9 files changed, 111 insertions(+), 46 deletions(-) create mode 100644 src/dateExtensions/types.ts diff --git a/package.json b/package.json index 11198fd3..eb07b08d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "*.scss" ], "scripts": { - "dev": "PORT=8080 PUBLIC_PATH=/instructor openedx dev", + "dev": "PORT=8081 PUBLIC_PATH=/instructor openedx dev", "i18n_extract": "openedx formatjs extract", "lint": "openedx lint .", "lint:fix": "openedx lint --fix .", diff --git a/src/dateExtensions/DateExtensionsPage.test.tsx b/src/dateExtensions/DateExtensionsPage.test.tsx index 013ce4cf..cf1bf2b3 100644 --- a/src/dateExtensions/DateExtensionsPage.test.tsx +++ b/src/dateExtensions/DateExtensionsPage.test.tsx @@ -22,7 +22,7 @@ const mockDateExtensions = [ describe('DateExtensionsPage', () => { beforeEach(() => { (useDateExtensions as jest.Mock).mockReturnValue({ - data: mockDateExtensions, + data: { count: mockDateExtensions.length, results: mockDateExtensions }, isLoading: false, }); }); @@ -55,7 +55,7 @@ describe('DateExtensionsPage', () => { it('shows loading state on table when fetching data', () => { (useDateExtensions as jest.Mock).mockReturnValue({ - data: [], + data: { count: 0, results: [] }, isLoading: true, }); render(); diff --git a/src/dateExtensions/DateExtensionsPage.tsx b/src/dateExtensions/DateExtensionsPage.tsx index b11a1a14..0b7ecb82 100644 --- a/src/dateExtensions/DateExtensionsPage.tsx +++ b/src/dateExtensions/DateExtensionsPage.tsx @@ -2,38 +2,18 @@ import { useIntl } from '@openedx/frontend-base'; import messages from './messages'; import DateExtensionsList from './components/DateExtensionsList'; import { Button, Container } from '@openedx/paragon'; -import { useDateExtensions } from './data/apiHook'; -import { useParams } from 'react-router-dom'; +import { LearnerDateExtension } from './types'; -// 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' }, -// ]; - -export interface User { - id: number, - username: string, - fullname: string, - email: string, - graded_subsection: string, - extended_due_date: string, -} +// 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(); - const { data = [], isLoading } = useDateExtensions(courseId ?? ''); - const handleResetExtensions = (user: User) => { + const handleResetExtensions = (user: LearnerDateExtension) => { // Implementation for resetting extensions will go here console.log(user); }; - const tableData = data.map(item => ({ - ...item, - reset: , - })); - return (

{intl.formatMessage(messages.dateExtensionsTitle)}

@@ -41,7 +21,7 @@ const DateExtensionsPage = () => {

filters

- +
); }; diff --git a/src/dateExtensions/components/DateExtensionsList.test.tsx b/src/dateExtensions/components/DateExtensionsList.test.tsx index bd2739b8..22e168f2 100644 --- a/src/dateExtensions/components/DateExtensionsList.test.tsx +++ b/src/dateExtensions/components/DateExtensionsList.test.tsx @@ -1,6 +1,8 @@ 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 = [ { @@ -13,27 +15,41 @@ const mockData = [ } ]; +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', () => { - renderComponent({ data: [], isLoading: true }); + (useDateExtensions as jest.Mock).mockReturnValue({ isLoading: true, data: { count: 0, results: [] } }); + renderComponent({}); expect(screen.getByRole('status')).toBeInTheDocument(); }); - it('renders table with data', () => { - renderComponent({ data: mockData }); + 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', () => { - renderComponent({ data: [] }); + (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 index a38da72a..a7a0f095 100644 --- a/src/dateExtensions/components/DateExtensionsList.tsx +++ b/src/dateExtensions/components/DateExtensionsList.tsx @@ -1,20 +1,39 @@ import { useIntl } from '@openedx/frontend-base'; -import { DataTable } from '@openedx/paragon'; +import { Button, DataTable } from '@openedx/paragon'; import messages from '../messages'; -import { User } from '../DateExtensionsPage'; +import { LearnerDateExtension } from '../types'; +import { useDateExtensions } from '../../data/apiHook'; +import { useParams } from 'react-router-dom'; +import { useState } from 'react'; -const DATE_EXTENSIONS_PAGE_SIZE = 25; +// 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' }, +// ]; + +export const DATE_EXTENSIONS_PAGE_SIZE = 25; export interface DateExtensionListProps { - data: User[], - isLoading?: boolean, + onResetExtensions?: (user: LearnerDateExtension) => void, +} + +interface DataTableFetchDataProps { + pageIndex: number, } const DateExtensionsList = ({ - data = [], - isLoading = false, + 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) }, @@ -25,9 +44,33 @@ const DateExtensionsList = ({ { accessor: 'reset', Header: intl.formatMessage(messages.reset) }, ]; - const totalItemCount = 25; + const tableData = data.results.map(item => ({ + ...item, + reset: , + })); + + const handleFetchData = (data: DataTableFetchDataProps) => { + setPage(data.pageIndex); + }; - return ; + return ( + + ); }; export default DateExtensionsList; diff --git a/src/dateExtensions/data/api.ts b/src/dateExtensions/data/api.ts index 96bfe6e8..3623ed68 100644 --- a/src/dateExtensions/data/api.ts +++ b/src/dateExtensions/data/api.ts @@ -1,7 +1,18 @@ import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base'; import { getApiBaseUrl } from '../../data/api'; +import { DateExtensionsResponse } from '../types'; -export const getDateExtensions = async (courseId) => { - const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/unit_extensions`); +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 index b27a2f46..4383e764 100644 --- a/src/dateExtensions/data/apiHook.ts +++ b/src/dateExtensions/data/apiHook.ts @@ -1,10 +1,10 @@ import { useQuery } from '@tanstack/react-query'; -import { getDateExtensions } from './api'; +import { getDateExtensions, PaginationQueryKeys } from './api'; import { dateExtensionsQueryKeys } from './queryKeys'; -export const useDateExtensions = (courseId: string) => ( +export const useDateExtensions = (courseId: string, pagination: PaginationQueryKeys) => ( useQuery({ queryKey: dateExtensionsQueryKeys.byCourse(courseId), - queryFn: () => getDateExtensions(courseId), + queryFn: () => getDateExtensions(courseId, pagination), }) ); 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/routes.tsx b/src/routes.tsx index 986c0a1b..18d74efd 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -25,7 +25,7 @@ const routes = [ element: }, { - path: '/date_extensions', + path: 'date_extensions', element: }, // { From 710c3a6e2b8a7cc58c2d08c56f5cb1a51963e5b1 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Thu, 13 Nov 2025 13:54:27 -0600 Subject: [PATCH 08/17] fix: use camelCase on messages --- src/dateExtensions/components/DateExtensionsList.tsx | 4 ++-- src/dateExtensions/messages.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/dateExtensions/components/DateExtensionsList.tsx b/src/dateExtensions/components/DateExtensionsList.tsx index a7a0f095..dbd8ad15 100644 --- a/src/dateExtensions/components/DateExtensionsList.tsx +++ b/src/dateExtensions/components/DateExtensionsList.tsx @@ -39,8 +39,8 @@ const DateExtensionsList = ({ { 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.graded_subsection) }, - { accessor: 'extended_due_date', Header: intl.formatMessage(messages.extended_due_date) }, + { accessor: 'graded_subsection', Header: intl.formatMessage(messages.gradedSubsection) }, + { accessor: 'extended_due_date', Header: intl.formatMessage(messages.extendedDueDate) }, { accessor: 'reset', Header: intl.formatMessage(messages.reset) }, ]; diff --git a/src/dateExtensions/messages.ts b/src/dateExtensions/messages.ts index f92a6317..5be9f42c 100644 --- a/src/dateExtensions/messages.ts +++ b/src/dateExtensions/messages.ts @@ -26,13 +26,13 @@ const messages = defineMessages({ defaultMessage: 'Email', description: 'Label for the email column in the date extensions table', }, - graded_subsection: { - id: 'instruct.dateExtensions.page.tableHeader.graded_subsection', + gradedSubsection: { + id: 'instruct.dateExtensions.page.tableHeader.gradedSubsection', defaultMessage: 'Graded Subsection', description: 'Label for the graded subsection column in the date extensions table', }, - extended_due_date: { - id: 'instruct.dateExtensions.page.tableHeader.extended_due_date', + extendedDueDate: { + id: 'instruct.dateExtensions.page.tableHeader.extendedDueDate', defaultMessage: 'Extended Due Date', description: 'Label for the extended due date column in the date extensions table', }, From 8726fc3279a418e526b00b60d3c31924d9131c18 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Fri, 14 Nov 2025 11:11:23 -0600 Subject: [PATCH 09/17] chore: improving querykeys --- package.json | 2 +- src/dateExtensions/DateExtensionsPage.test.tsx | 4 ++-- src/dateExtensions/components/DateExtensionsList.test.tsx | 4 ++-- src/dateExtensions/components/DateExtensionsList.tsx | 4 ++-- src/dateExtensions/data/apiHook.ts | 2 +- src/dateExtensions/data/queryKeys.ts | 4 +++- 6 files changed, 11 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index eb07b08d..11198fd3 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "*.scss" ], "scripts": { - "dev": "PORT=8081 PUBLIC_PATH=/instructor openedx dev", + "dev": "PORT=8080 PUBLIC_PATH=/instructor openedx dev", "i18n_extract": "openedx formatjs extract", "lint": "openedx lint .", "lint:fix": "openedx lint --fix .", diff --git a/src/dateExtensions/DateExtensionsPage.test.tsx b/src/dateExtensions/DateExtensionsPage.test.tsx index cf1bf2b3..04432825 100644 --- a/src/dateExtensions/DateExtensionsPage.test.tsx +++ b/src/dateExtensions/DateExtensionsPage.test.tsx @@ -2,9 +2,9 @@ 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'; +import { useDateExtensions } from './data/apiHook'; -jest.mock('../data/apiHook', () => ({ +jest.mock('./data/apiHook', () => ({ useDateExtensions: jest.fn(), })); diff --git a/src/dateExtensions/components/DateExtensionsList.test.tsx b/src/dateExtensions/components/DateExtensionsList.test.tsx index 22e168f2..29c349ae 100644 --- a/src/dateExtensions/components/DateExtensionsList.test.tsx +++ b/src/dateExtensions/components/DateExtensionsList.test.tsx @@ -2,7 +2,7 @@ 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'; +import { useDateExtensions } from '../data/apiHook'; const mockData = [ { @@ -15,7 +15,7 @@ const mockData = [ } ]; -jest.mock('../../data/apiHook', () => ({ +jest.mock('../data/apiHook', () => ({ useDateExtensions: jest.fn(), })); diff --git a/src/dateExtensions/components/DateExtensionsList.tsx b/src/dateExtensions/components/DateExtensionsList.tsx index dbd8ad15..7b089e47 100644 --- a/src/dateExtensions/components/DateExtensionsList.tsx +++ b/src/dateExtensions/components/DateExtensionsList.tsx @@ -2,7 +2,7 @@ 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 { useDateExtensions } from '../data/apiHook'; import { useParams } from 'react-router-dom'; import { useState } from 'react'; @@ -12,7 +12,7 @@ import { useState } from 'react'; // { id: 2, username: 'dianaSalas', fullname: 'Diana Villalvazo', email: 'diana.villalvazo@example.com', graded_subsection: 'Three body diagrams', extended_due_date: '2026-07-15' }, // ]; -export const DATE_EXTENSIONS_PAGE_SIZE = 25; +const DATE_EXTENSIONS_PAGE_SIZE = 25; export interface DateExtensionListProps { onResetExtensions?: (user: LearnerDateExtension) => void, diff --git a/src/dateExtensions/data/apiHook.ts b/src/dateExtensions/data/apiHook.ts index 4383e764..e0a42a04 100644 --- a/src/dateExtensions/data/apiHook.ts +++ b/src/dateExtensions/data/apiHook.ts @@ -4,7 +4,7 @@ import { dateExtensionsQueryKeys } from './queryKeys'; export const useDateExtensions = (courseId: string, pagination: PaginationQueryKeys) => ( useQuery({ - queryKey: dateExtensionsQueryKeys.byCourse(courseId), + queryKey: dateExtensionsQueryKeys.byCoursePaginated(courseId, pagination), queryFn: () => getDateExtensions(courseId, pagination), }) ); diff --git a/src/dateExtensions/data/queryKeys.ts b/src/dateExtensions/data/queryKeys.ts index 752aec89..9bfd9845 100644 --- a/src/dateExtensions/data/queryKeys.ts +++ b/src/dateExtensions/data/queryKeys.ts @@ -1,6 +1,8 @@ import { appId } from '../../constants'; +import { PaginationQueryKeys } from './api'; export const dateExtensionsQueryKeys = { all: [appId, 'dateExtensions'] as const, - byCourse: (courseId: string) => [appId, 'dateExtensions', courseId] as const, + byCourse: (courseId: string) => [...dateExtensionsQueryKeys.all, courseId] as const, + byCoursePaginated: (courseId: string, pagination: PaginationQueryKeys) => [...dateExtensionsQueryKeys.byCourse(courseId), pagination.page] as const, }; From e905153dc23e44020897ff87a231c9cbf4c4af34 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Thu, 6 Nov 2025 19:52:56 -0600 Subject: [PATCH 10/17] feat: reset extensions modal --- src/dateExtensions/DateExtensionsPage.tsx | 32 +++++++++++-- .../components/ResetExtensionsModal.test.tsx | 45 +++++++++++++++++++ .../components/ResetExtensionsModal.tsx | 35 +++++++++++++++ src/dateExtensions/data/api.ts | 5 +++ src/dateExtensions/data/apiHook.ts | 11 ++++- src/dateExtensions/messages.ts | 20 +++++++++ 6 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 src/dateExtensions/components/ResetExtensionsModal.test.tsx create mode 100644 src/dateExtensions/components/ResetExtensionsModal.tsx diff --git a/src/dateExtensions/DateExtensionsPage.tsx b/src/dateExtensions/DateExtensionsPage.tsx index 0b7ecb82..0c23504e 100644 --- a/src/dateExtensions/DateExtensionsPage.tsx +++ b/src/dateExtensions/DateExtensionsPage.tsx @@ -1,17 +1,35 @@ +import { useState } from 'react'; import { useIntl } from '@openedx/frontend-base'; +import { Button, Container } from '@openedx/paragon'; import messages from './messages'; import DateExtensionsList from './components/DateExtensionsList'; -import { Button, Container } 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'; const DateExtensionsPage = () => { const intl = useIntl(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); const handleResetExtensions = (user: LearnerDateExtension) => { - // Implementation for resetting extensions will go here - console.log(user); + setIsModalOpen(true); + setSelectedUser(user); + }; + + const handleConfirmReset = () => { + if (selectedUser) { + // Call the API to reset the extensions for the selected user + console.log(`Resetting extensions for user: ${selectedUser.username}`); + } + setIsModalOpen(false); + setSelectedUser(null); + }; + + const handleCancelReset = () => { + setIsModalOpen(false); + setSelectedUser(null); }; return ( @@ -22,6 +40,14 @@ const DateExtensionsPage = () => { + ); }; 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/data/api.ts b/src/dateExtensions/data/api.ts index 3623ed68..e1e6b75b 100644 --- a/src/dateExtensions/data/api.ts +++ b/src/dateExtensions/data/api.ts @@ -16,3 +16,8 @@ export const getDateExtensions = async ( ); 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); +}; diff --git a/src/dateExtensions/data/apiHook.ts b/src/dateExtensions/data/apiHook.ts index e0a42a04..1e1437b5 100644 --- a/src/dateExtensions/data/apiHook.ts +++ b/src/dateExtensions/data/apiHook.ts @@ -1,5 +1,5 @@ -import { useQuery } from '@tanstack/react-query'; -import { getDateExtensions, PaginationQueryKeys } from './api'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { getDateExtensions, PaginationQueryKeys, resetDateExtension } from './api'; import { dateExtensionsQueryKeys } from './queryKeys'; export const useDateExtensions = (courseId: string, pagination: PaginationQueryKeys) => ( @@ -8,3 +8,10 @@ export const useDateExtensions = (courseId: string, pagination: PaginationQueryK queryFn: () => getDateExtensions(courseId, pagination), }) ); + +export const useResetDateExtensionMutation = () => { + return useMutation({ + mutationFn: ({ courseId, userId }: { courseId: string, userId: number }) => + resetDateExtension(courseId, userId), + }); +}; diff --git a/src/dateExtensions/messages.ts b/src/dateExtensions/messages.ts index 5be9f42c..45cb67a9 100644 --- a/src/dateExtensions/messages.ts +++ b/src/dateExtensions/messages.ts @@ -41,6 +41,26 @@ const messages = defineMessages({ 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', + }, }); export default messages; From 744b8cd9a8505e84374012747813dcf0621970b0 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Mon, 10 Nov 2025 12:43:27 -0600 Subject: [PATCH 11/17] feat: success message toast --- src/dateExtensions/DateExtensionsPage.tsx | 47 +++++++++++++++++------ src/dateExtensions/data/apiHook.ts | 6 ++- src/dateExtensions/messages.ts | 5 +++ src/main.scss | 5 +++ 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/dateExtensions/DateExtensionsPage.tsx b/src/dateExtensions/DateExtensionsPage.tsx index 0c23504e..63a169c3 100644 --- a/src/dateExtensions/DateExtensionsPage.tsx +++ b/src/dateExtensions/DateExtensionsPage.tsx @@ -1,35 +1,54 @@ import { useState } from 'react'; +import { useParams } from 'react-router-dom'; import { useIntl } from '@openedx/frontend-base'; -import { Button, Container } from '@openedx/paragon'; +import { AlertModal, Button, Container, Toast } from '@openedx/paragon'; import messages from './messages'; import DateExtensionsList from './components/DateExtensionsList'; import ResetExtensionsModal from './components/ResetExtensionsModal'; import { LearnerDateExtension } from './types'; +import { useResetDateExtensionMutation } from '../data/apiHook'; // 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 [isModalOpen, setIsModalOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(null); + const [successMessage, setSuccessMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); const handleResetExtensions = (user: LearnerDateExtension) => { setIsModalOpen(true); setSelectedUser(user); }; - const handleConfirmReset = () => { - if (selectedUser) { - // Call the API to reset the extensions for the selected user - console.log(`Resetting extensions for user: ${selectedUser.username}`); - } + const handleCloseModal = () => { setIsModalOpen(false); setSelectedUser(null); }; - const handleCancelReset = () => { - setIsModalOpen(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 + }); + } }; return ( @@ -44,10 +63,16 @@ const DateExtensionsPage = () => { isOpen={isModalOpen} message={intl.formatMessage(messages.resetConfirmationMessage)} title={intl.formatMessage(messages.resetConfirmationHeader, { username: selectedUser?.username })} - onCancelReset={handleCancelReset} - onClose={handleCancelReset} + onCancelReset={handleCloseModal} + onClose={handleCloseModal} onConfirmReset={handleConfirmReset} /> + {}} className="text-break"> + {successMessage} + + setErrorMessage('')}>{intl.formatMessage(messages.close)}}> + {errorMessage} + ); }; diff --git a/src/dateExtensions/data/apiHook.ts b/src/dateExtensions/data/apiHook.ts index 1e1437b5..3e51560b 100644 --- a/src/dateExtensions/data/apiHook.ts +++ b/src/dateExtensions/data/apiHook.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { getDateExtensions, PaginationQueryKeys, resetDateExtension } from './api'; import { dateExtensionsQueryKeys } from './queryKeys'; @@ -10,8 +10,12 @@ export const useDateExtensions = (courseId: string, pagination: PaginationQueryK ); export const useResetDateExtensionMutation = () => { + const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ courseId, userId }: { courseId: string, userId: number }) => resetDateExtension(courseId, userId), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: dateExtensionsQueryKeys.all }); + }, }); }; diff --git a/src/dateExtensions/messages.ts b/src/dateExtensions/messages.ts index 45cb67a9..eb8cfdcb 100644 --- a/src/dateExtensions/messages.ts +++ b/src/dateExtensions/messages.ts @@ -61,6 +61,11 @@ const messages = defineMessages({ 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', + }, }); export default messages; 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; +} From 7195ab1263fafbc7928e6c54efc7e39af5fc800f Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Mon, 10 Nov 2025 13:10:00 -0600 Subject: [PATCH 12/17] test: edit unit tests --- .../DateExtensionsPage.test.tsx | 40 ++++++++++++++++++- src/dateExtensions/DateExtensionsPage.tsx | 12 +++--- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/dateExtensions/DateExtensionsPage.test.tsx b/src/dateExtensions/DateExtensionsPage.test.tsx index 04432825..b390d214 100644 --- a/src/dateExtensions/DateExtensionsPage.test.tsx +++ b/src/dateExtensions/DateExtensionsPage.test.tsx @@ -1,11 +1,13 @@ 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 } from './data/apiHook'; +import { useDateExtensions, useResetDateExtensionMutation } from './data/apiHook'; jest.mock('./data/apiHook', () => ({ useDateExtensions: jest.fn(), + useResetDateExtensionMutation: jest.fn(), })); const mockDateExtensions = [ @@ -19,12 +21,17 @@ 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 = () => ( @@ -67,4 +74,35 @@ describe('DateExtensionsPage', () => { 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 index 63a169c3..423129d2 100644 --- a/src/dateExtensions/DateExtensionsPage.tsx +++ b/src/dateExtensions/DateExtensionsPage.tsx @@ -6,7 +6,7 @@ import messages from './messages'; import DateExtensionsList from './components/DateExtensionsList'; import ResetExtensionsModal from './components/ResetExtensionsModal'; import { LearnerDateExtension } from './types'; -import { useResetDateExtensionMutation } from '../data/apiHook'; +import { useResetDateExtensionMutation } from './data/apiHook'; // 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'; @@ -14,18 +14,18 @@ const DateExtensionsPage = () => { const intl = useIntl(); const { courseId } = useParams<{ courseId: string }>(); const { mutate: resetMutation } = useResetDateExtensionMutation(); - const [isModalOpen, setIsModalOpen] = useState(false); + const [isResetModalOpen, setIsResetModalOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(null); const [successMessage, setSuccessMessage] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const handleResetExtensions = (user: LearnerDateExtension) => { - setIsModalOpen(true); + setIsResetModalOpen(true); setSelectedUser(user); }; const handleCloseModal = () => { - setIsModalOpen(false); + setIsResetModalOpen(false); setSelectedUser(null); }; @@ -60,7 +60,7 @@ const DateExtensionsPage = () => { { {}} className="text-break"> {successMessage} - setErrorMessage('')}>{intl.formatMessage(messages.close)}}> + setErrorMessage('')}>{intl.formatMessage(messages.close)}}> {errorMessage} From 07f25480d574fc8c08e2c8e7b92095b82433edc6 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Fri, 14 Nov 2025 11:16:40 -0600 Subject: [PATCH 13/17] chore: improving querykeys --- src/dateExtensions/data/apiHook.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dateExtensions/data/apiHook.ts b/src/dateExtensions/data/apiHook.ts index 3e51560b..6cbfefa9 100644 --- a/src/dateExtensions/data/apiHook.ts +++ b/src/dateExtensions/data/apiHook.ts @@ -14,8 +14,8 @@ export const useResetDateExtensionMutation = () => { return useMutation({ mutationFn: ({ courseId, userId }: { courseId: string, userId: number }) => resetDateExtension(courseId, userId), - onSettled: () => { - queryClient.invalidateQueries({ queryKey: dateExtensionsQueryKeys.all }); + onSuccess: ({ courseId }) => { + queryClient.invalidateQueries({ queryKey: dateExtensionsQueryKeys.byCourse(courseId) }); }, }); }; From 50b081d40996f98e5f700d799db710f868b54e74 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Thu, 20 Nov 2025 18:00:22 -0600 Subject: [PATCH 14/17] feat: add individual extension modal --- .../SpecifyLearnerField.tsx | 19 ++++ .../DateExtensionsPage.test.tsx | 1 + src/dateExtensions/DateExtensionsPage.tsx | 31 ++++++- .../components/AddExtensionModal.tsx | 92 +++++++++++++++++++ src/dateExtensions/data/api.ts | 12 +++ src/dateExtensions/data/apiHook.ts | 13 ++- src/dateExtensions/messages.ts | 35 +++++++ 7 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 src/components/SpecifyLearnerField/SpecifyLearnerField.tsx create mode 100644 src/dateExtensions/components/AddExtensionModal.tsx 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 index b390d214..dbd7b565 100644 --- a/src/dateExtensions/DateExtensionsPage.test.tsx +++ b/src/dateExtensions/DateExtensionsPage.test.tsx @@ -8,6 +8,7 @@ import { useDateExtensions, useResetDateExtensionMutation } from './data/apiHook jest.mock('./data/apiHook', () => ({ useDateExtensions: jest.fn(), useResetDateExtensionMutation: jest.fn(), + useAddDateExtensionMutation: jest.fn(() => ({ mutate: jest.fn() })), })); const mockDateExtensions = [ diff --git a/src/dateExtensions/DateExtensionsPage.tsx b/src/dateExtensions/DateExtensionsPage.tsx index 423129d2..24c9cd73 100644 --- a/src/dateExtensions/DateExtensionsPage.tsx +++ b/src/dateExtensions/DateExtensionsPage.tsx @@ -6,18 +6,21 @@ import messages from './messages'; import DateExtensionsList from './components/DateExtensionsList'; import ResetExtensionsModal from './components/ResetExtensionsModal'; import { LearnerDateExtension } from './types'; -import { useResetDateExtensionMutation } from './data/apiHook'; +import { useAddDateExtensionMutation, useResetDateExtensionMutation } from './data/apiHook'; +import AddExtensionModal from './components/AddExtensionModal'; // 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 { 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 handleResetExtensions = (user: LearnerDateExtension) => { setIsResetModalOpen(true); @@ -51,14 +54,36 @@ const DateExtensionsPage = () => { } }; + 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)}

filters

- +
+ setIsAddExtensionModalOpen(false)} + onSubmit={handleAddExtension} + /> 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 options = [ + { label: 'is an example', value: 'example' }, + { label: 'another example', value: 'another' } + ]; + + const handleSubmit = () => { + onSubmit({ + email_or_username: 'dianasalas', + block_id: 'block-v1:DV-edtech+check+2025-05+type@sequential+block@a9500056bbb544ea82fad0d3957c6932', + due_datetime: '2025-01-21 00:00:00', + reason: 'Personal reasons' + }); + }; + + return ( + + +

{title}

+
+ +
+

{intl.formatMessage(messages.extensionInstructions)}

+ +
+
+
+ {}} /> +
+
+ {intl.formatMessage(messages.selectGradedSubsection)} + + { + options.map((option) => ( + {}}> + {option.label} + + )) + } + +
+
+
+
+

{intl.formatMessage(messages.defineExtension)}

+ + {intl.formatMessage(messages.extensionDate)}: + +
+ + +
+
+ + {intl.formatMessage(messages.reasonForExtension)}: + + +
+
+
+
+
+ + + + + + +
+ ); +}; + +export default AddExtensionModal; diff --git a/src/dateExtensions/data/api.ts b/src/dateExtensions/data/api.ts index e1e6b75b..b47ff697 100644 --- a/src/dateExtensions/data/api.ts +++ b/src/dateExtensions/data/api.ts @@ -21,3 +21,15 @@ 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); +}; diff --git a/src/dateExtensions/data/apiHook.ts b/src/dateExtensions/data/apiHook.ts index 6cbfefa9..d213ff85 100644 --- a/src/dateExtensions/data/apiHook.ts +++ b/src/dateExtensions/data/apiHook.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { getDateExtensions, PaginationQueryKeys, resetDateExtension } from './api'; +import { getDateExtensions, PaginationQueryKeys, resetDateExtension, addDateExtension } from './api'; import { dateExtensionsQueryKeys } from './queryKeys'; export const useDateExtensions = (courseId: string, pagination: PaginationQueryKeys) => ( @@ -19,3 +19,14 @@ export const useResetDateExtensionMutation = () => { }, }); }; + +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) }); + }, + }); +}; diff --git a/src/dateExtensions/messages.ts b/src/dateExtensions/messages.ts index eb8cfdcb..17684484 100644 --- a/src/dateExtensions/messages.ts +++ b/src/dateExtensions/messages.ts @@ -66,6 +66,41 @@ const messages = defineMessages({ 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', + }, }); export default messages; From f99de344646e8e2338787d8e3cd60234cbae6049 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Fri, 28 Nov 2025 12:21:02 -0500 Subject: [PATCH 15/17] chore: abstract graded select --- .../components/AddExtensionModal.tsx | 105 ++++++++++-------- .../components/SelectGradedSubsection.tsx | 41 +++++++ src/dateExtensions/data/api.ts | 7 ++ src/dateExtensions/data/apiHook.ts | 11 +- src/dateExtensions/data/queryKeys.ts | 5 + 5 files changed, 120 insertions(+), 49 deletions(-) create mode 100644 src/dateExtensions/components/SelectGradedSubsection.tsx diff --git a/src/dateExtensions/components/AddExtensionModal.tsx b/src/dateExtensions/components/AddExtensionModal.tsx index 60afe62e..b22644ae 100644 --- a/src/dateExtensions/components/AddExtensionModal.tsx +++ b/src/dateExtensions/components/AddExtensionModal.tsx @@ -1,7 +1,9 @@ -import { ActionRow, Button, FormAutosuggest, FormAutosuggestOption, FormControl, FormGroup, FormLabel, ModalDialog } from '@openedx/paragon'; +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, @@ -17,74 +19,83 @@ interface AddExtensionModalProps { 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 options = [ - { label: 'is an example', value: 'example' }, - { label: 'another example', value: 'another' } - ]; - - const handleSubmit = () => { + const handleSubmit = (event) => { + event.preventDefault(); + const { email_or_username, block_id, due_date, due_time, reason } = formData; onSubmit({ - email_or_username: 'dianasalas', - block_id: 'block-v1:DV-edtech+check+2025-05+type@sequential+block@a9500056bbb544ea82fad0d3957c6932', - due_datetime: '2025-01-21 00:00:00', - reason: 'Personal reasons' + 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)}

- +
+ +

{title}

+
+ +
+

{intl.formatMessage(messages.extensionInstructions)}

{}} />
- {intl.formatMessage(messages.selectGradedSubsection)} - - { - options.map((option) => ( - {}}> - {option.label} - - )) - } - +

{intl.formatMessage(messages.defineExtension)}

- - {intl.formatMessage(messages.extensionDate)}: - -
- - -
-
+ + + {intl.formatMessage(messages.extensionDate)}: + +
+ + +
+
+ {intl.formatMessage(messages.reasonForExtension)}: - -
+ +
- -
-
- - - - - - +
+
+ + + + + + +
); }; diff --git a/src/dateExtensions/components/SelectGradedSubsection.tsx b/src/dateExtensions/components/SelectGradedSubsection.tsx new file mode 100644 index 00000000..7ab70b53 --- /dev/null +++ b/src/dateExtensions/components/SelectGradedSubsection.tsx @@ -0,0 +1,41 @@ +import { FormLabel, FormControl, FormGroup } from '@openedx/paragon'; +import { useGradedSubsections } from '../../data/apiHook'; +import { useParams } from 'react-router'; + +interface SelectGradedSubsectionProps { + label?: string, + placeholder: 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, onChange }: SelectGradedSubsectionProps) => { + const { courseId = '' } = useParams<{ courseId: string }>(); + const { data = { results: [] } } = useGradedSubsections(courseId); + const selectOptions = [{ displayName: placeholder, subsectionId: '' }, ...data.results]; + 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 index b47ff697..89717bfe 100644 --- a/src/dateExtensions/data/api.ts +++ b/src/dateExtensions/data/api.ts @@ -33,3 +33,10 @@ export const addDateExtension = async (courseId, extensionData: AddDateExtension 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 index d213ff85..fd36de16 100644 --- a/src/dateExtensions/data/apiHook.ts +++ b/src/dateExtensions/data/apiHook.ts @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { getDateExtensions, PaginationQueryKeys, resetDateExtension, addDateExtension } from './api'; -import { dateExtensionsQueryKeys } from './queryKeys'; +import { getDateExtensions, PaginationQueryKeys, resetDateExtension, addDateExtension, getGradedSubsections } from './api'; +import { dateExtensionsQueryKeys, gradedSubsectionsQueryKeys } from './queryKeys'; export const useDateExtensions = (courseId: string, pagination: PaginationQueryKeys) => ( useQuery({ @@ -30,3 +30,10 @@ export const useAddDateExtensionMutation = () => { }, }); }; + +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 index 9bfd9845..1348c84c 100644 --- a/src/dateExtensions/data/queryKeys.ts +++ b/src/dateExtensions/data/queryKeys.ts @@ -6,3 +6,8 @@ export const dateExtensionsQueryKeys = { 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, +}; From 710584f04529bb3e7069e33000927ae657acafc6 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Fri, 28 Nov 2025 13:14:15 -0500 Subject: [PATCH 16/17] feat: graded subsection filter --- src/dateExtensions/DateExtensionsPage.tsx | 8 +++++++- src/dateExtensions/messages.ts | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/dateExtensions/DateExtensionsPage.tsx b/src/dateExtensions/DateExtensionsPage.tsx index 24c9cd73..bb87ffc0 100644 --- a/src/dateExtensions/DateExtensionsPage.tsx +++ b/src/dateExtensions/DateExtensionsPage.tsx @@ -8,6 +8,7 @@ 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'; // 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'; @@ -74,7 +75,12 @@ const DateExtensionsPage = () => {

{intl.formatMessage(messages.dateExtensionsTitle)}

-

filters

+
+ {}} + /> +
diff --git a/src/dateExtensions/messages.ts b/src/dateExtensions/messages.ts index 17684484..4f2a6b1c 100644 --- a/src/dateExtensions/messages.ts +++ b/src/dateExtensions/messages.ts @@ -101,6 +101,11 @@ const messages = defineMessages({ 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', + }, }); export default messages; From 59540b0eec682f9157a38ea2bbbd065834041b8a Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Fri, 28 Nov 2025 14:04:53 -0500 Subject: [PATCH 17/17] feat: search field --- .../DateExtensionsPage.test.tsx | 19 +++++++++++++-- src/dateExtensions/DateExtensionsPage.tsx | 22 +++++++++++++---- .../components/DateExtensionsList.tsx | 8 ++++++- .../components/SelectGradedSubsection.tsx | 19 +++++++++++---- src/dateExtensions/data/api.ts | 24 +++++++++++++++++-- src/dateExtensions/data/apiHook.ts | 9 +++---- src/dateExtensions/messages.ts | 5 ++++ 7 files changed, 88 insertions(+), 18 deletions(-) diff --git a/src/dateExtensions/DateExtensionsPage.test.tsx b/src/dateExtensions/DateExtensionsPage.test.tsx index dbd7b565..99ed81df 100644 --- a/src/dateExtensions/DateExtensionsPage.test.tsx +++ b/src/dateExtensions/DateExtensionsPage.test.tsx @@ -3,12 +3,13 @@ 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, useResetDateExtensionMutation } from './data/apiHook'; +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 = [ @@ -22,6 +23,13 @@ const mockDateExtensions = [ }, ]; +const mockGradedSubsections = [ + { + subsectionId: 'subsection-1block-v1:edX+DemoX+2015+type@problem+block@618c5933b8b544e4a4cc103d3e508378', + displayName: 'Three body diagrams' + } +]; + const mutateMock = jest.fn(); describe('DateExtensionsPage', () => { @@ -33,6 +41,13 @@ describe('DateExtensionsPage', () => { (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 = () => ( @@ -58,7 +73,7 @@ describe('DateExtensionsPage', () => { it('renders date extensions list', () => { render(); expect(screen.getByText('Ed Byun')).toBeInTheDocument(); - expect(screen.getByText('Three body diagrams')).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Three body diagrams' })).toBeInTheDocument(); }); it('shows loading state on table when fetching data', () => { diff --git a/src/dateExtensions/DateExtensionsPage.tsx b/src/dateExtensions/DateExtensionsPage.tsx index bb87ffc0..778728ae 100644 --- a/src/dateExtensions/DateExtensionsPage.tsx +++ b/src/dateExtensions/DateExtensionsPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useParams } from 'react-router-dom'; import { useIntl } from '@openedx/frontend-base'; -import { AlertModal, Button, Container, Toast } from '@openedx/paragon'; +import { AlertModal, Button, Container, FormControl, Icon, Toast } from '@openedx/paragon'; import messages from './messages'; import DateExtensionsList from './components/DateExtensionsList'; import ResetExtensionsModal from './components/ResetExtensionsModal'; @@ -9,6 +9,7 @@ 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'; @@ -22,6 +23,8 @@ const DateExtensionsPage = () => { 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); @@ -75,15 +78,26 @@ const DateExtensionsPage = () => {

{intl.formatMessage(messages.dateExtensionsTitle)}

-
+
+ setSearchedLearner(e.target.value)} + placeholder={intl.formatMessage(messages.searchLearnerPlaceholder)} + trailingElement={} + value={searchedLearner} + /> {}} + onChange={(e) => setGradedSubsectionFilter(e.target.value)} + value={gradedSubsectionFilter} />
- + void, + searchedLearner?: string, + gradedSubsectionFilter?: string, } interface DataTableFetchDataProps { @@ -24,13 +26,17 @@ interface DataTableFetchDataProps { 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 + pageSize: DATE_EXTENSIONS_PAGE_SIZE, + search: searchedLearner, + gradedSubsection: gradedSubsectionFilter }); const pageCount = Math.ceil(data.count / DATE_EXTENSIONS_PAGE_SIZE); diff --git a/src/dateExtensions/components/SelectGradedSubsection.tsx b/src/dateExtensions/components/SelectGradedSubsection.tsx index 7ab70b53..23b8f0ce 100644 --- a/src/dateExtensions/components/SelectGradedSubsection.tsx +++ b/src/dateExtensions/components/SelectGradedSubsection.tsx @@ -1,10 +1,11 @@ import { FormLabel, FormControl, FormGroup } from '@openedx/paragon'; -import { useGradedSubsections } from '../../data/apiHook'; +import { useGradedSubsections } from '../data/apiHook'; import { useParams } from 'react-router'; interface SelectGradedSubsectionProps { label?: string, placeholder: string, + value?: string, onChange: (event: React.ChangeEvent) => void, } @@ -14,10 +15,11 @@ interface SelectGradedSubsectionProps { // { displayName: 'another example', subsectionId: 'another' } // ]; -const SelectGradedSubsection = ({ label, placeholder, onChange }: SelectGradedSubsectionProps) => { +const SelectGradedSubsection = ({ label, placeholder, value, onChange }: SelectGradedSubsectionProps) => { const { courseId = '' } = useParams<{ courseId: string }>(); - const { data = { results: [] } } = useGradedSubsections(courseId); - const selectOptions = [{ displayName: placeholder, subsectionId: '' }, ...data.results]; + const { data = { items: [] } } = useGradedSubsections(courseId); + const selectOptions = [{ displayName: placeholder, subsectionId: '' }, ...data.items]; + const handleChange = (event: React.ChangeEvent) => { onChange(event); }; @@ -25,7 +27,14 @@ const SelectGradedSubsection = ({ label, placeholder, onChange }: SelectGradedSu return ( {label && {label}} - + { selectOptions.map((option) => (