diff --git a/package-lock.json b/package-lock.json index d86341cc98..079935dd9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,7 +57,7 @@ "moment-shortformat": "^2.1.0", "prop-types": "^15.8.1", "react": "^18.3.1", - "react-datepicker": "^4.13.0", + "react-datepicker": "^7.6.0", "react-dom": "^18.3.1", "react-error-boundary": "^4.0.13", "react-helmet": "^6.1.0", @@ -2995,28 +2995,62 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", - "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.9" + "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.13", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", - "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz", + "integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.15", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.15.tgz", + "integrity": "sha512-0LGxhBi3BB1DwuSNQAmuaSuertFzNAerlMdPbotjTVnvPtdOs7CkrHLaev5NIXemhzDXNC0tFzuseut7cWA5mw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.5", + "@floating-ui/utils": "^0.2.10", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.5.tgz", + "integrity": "sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.9" + "@floating-ui/dom": "^1.7.3" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" } }, + "node_modules/@floating-ui/react/node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" + }, "node_modules/@floating-ui/utils": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", - "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, "node_modules/@formatjs/cli": { @@ -8620,6 +8654,15 @@ "node": ">=0.10.0" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -9398,19 +9441,13 @@ } }, "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, "node_modules/debounce": { @@ -18847,21 +18884,18 @@ } }, "node_modules/react-datepicker": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-4.25.0.tgz", - "integrity": "sha512-zB7CSi44SJ0sqo8hUQ3BF1saE/knn7u25qEMTO1CQGofY1VAKahO8k9drZtp0cfW1DMfoYLR3uSY1/uMvbEzbg==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-7.6.0.tgz", + "integrity": "sha512-9cQH6Z/qa4LrGhzdc3XoHbhrxNcMi9MKjZmYgF/1MNNaJwvdSjv3Xd+jjvrEEbKEf71ZgCA3n7fQbdwd70qCRw==", "license": "MIT", "dependencies": { - "@popperjs/core": "^2.11.8", - "classnames": "^2.2.6", - "date-fns": "^2.30.0", - "prop-types": "^15.7.2", - "react-onclickoutside": "^6.13.0", - "react-popper": "^2.3.0" + "@floating-ui/react": "^0.27.0", + "clsx": "^2.1.1", + "date-fns": "^3.6.0" }, "peerDependencies": { - "react": "^16.9.0 || ^17 || ^18", - "react-dom": "^16.9.0 || ^17 || ^18" + "react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "node_modules/react-dev-utils": { diff --git a/package.json b/package.json index db3a39dde7..7e4149f42f 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "moment-shortformat": "^2.1.0", "prop-types": "^15.8.1", "react": "^18.3.1", - "react-datepicker": "^4.13.0", + "react-datepicker": "^7.6.0", "react-dom": "^18.3.1", "react-error-boundary": "^4.0.13", "react-helmet": "^6.1.0", diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index 5d3b540f9d..5519ca00e9 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -934,7 +934,7 @@ describe('', () => { it('check configure modal for section', async () => { const { findByTestId, findAllByTestId } = renderComponent(); const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; - const newReleaseDateIso = '2025-09-10T22:00:00Z'; + const newReleaseDateIso = '2025-09-10T22:00:00.000Z'; const newReleaseDate = '09/10/2025'; axiosMock .onPost(getCourseItemApiUrl(section.id), { @@ -999,7 +999,7 @@ describe('', () => { prereqMinCompletion: 100, metadata: { visible_to_staff_only: null, - due: '2025-09-10T05:00:00Z', + due: '2025-09-10T05:00:00.000Z', hide_after_due: true, show_correctness: 'always', is_practice_exam: false, @@ -1008,7 +1008,7 @@ describe('', () => { exam_review_rules: '', default_time_limit_minutes: 3270, is_onboarding_exam: false, - start: '2025-08-10T00:00:00Z', + start: '2025-08-10T00:00:00.000Z', }, }; diff --git a/src/course-updates/CourseUpdates.test.jsx b/src/course-updates/CourseUpdates.test.jsx index ab8e1a8022..e491fe8837 100644 --- a/src/course-updates/CourseUpdates.test.jsx +++ b/src/course-updates/CourseUpdates.test.jsx @@ -389,5 +389,38 @@ describe('', () => { expect(getByText(courseHandoutsMock.data)).toBeVisible(); expect(getByText(messages.savingHandoutsErrorDescription.defaultMessage)); }); + + it.only('should trigger handleUpdatesSubmit when submitting the form', async () => { + const { getByRole, getByText } = render(); + + // Open form + const newUpdateButton = getByRole('button', { name: messages.newUpdateButton.defaultMessage }); + fireEvent.click(newUpdateButton); + + // Wait for form + await waitFor(() => { + expect(getByText('Add new update')).toBeInTheDocument(); + }); + + // Mock POST response + const submittedUpdate = { + content: '

Submitted update content

', + date: 'August 15, 2025', + }; + + axiosMock + .onPost(getCourseUpdatesApiUrl(courseId)) + .reply(200, submittedUpdate); + + // Simulate form submit by clicking the Save button + const saveButton = getByRole('button', { name: /Post/i }); // Match case-insensitively + fireEvent.click(saveButton); + + // Expect new update to be rendered + await waitFor(() => { + expect(getByText('Submitted update content')).toBeInTheDocument(); + expect(getByText('August 15, 2025')).toBeInTheDocument(); + }); + }); }); }); diff --git a/src/course-updates/hooks.jsx b/src/course-updates/hooks.jsx index 8bf1d36616..1dcfd37c62 100644 --- a/src/course-updates/hooks.jsx +++ b/src/course-updates/hooks.jsx @@ -4,7 +4,6 @@ import { useEffect, useState } from 'react'; import { useToggle } from '@openedx/paragon'; import { COMMA_SEPARATED_DATE_FORMAT } from '../constants'; -import { convertToDateFromString } from '../utils'; import { getCourseHandouts, getCourseUpdates } from './data/selectors'; import { REQUEST_TYPES } from './constants'; import { @@ -56,7 +55,7 @@ const useCourseUpdates = ({ courseId }) => { }; const handleUpdatesSubmit = (data) => { - const dateWithoutTimezone = convertToDateFromString(data.date); + const dateWithoutTimezone = (data.date); const dataToSend = { ...data, date: moment(dateWithoutTimezone).format(COMMA_SEPARATED_DATE_FORMAT), diff --git a/src/course-updates/hooks.test.jsx b/src/course-updates/hooks.test.jsx new file mode 100644 index 0000000000..a64a6b25a4 --- /dev/null +++ b/src/course-updates/hooks.test.jsx @@ -0,0 +1,151 @@ +import moment from 'moment'; +import { REQUEST_TYPES } from './constants'; +import { + createCourseUpdateQuery, + editCourseUpdateQuery, + editCourseHandoutsQuery, +} from './data/thunk'; +import { COMMA_SEPARATED_DATE_FORMAT } from '../constants'; + +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), + useSelector: jest.fn(), +})); + +jest.mock('./data/thunk', () => ({ + createCourseUpdateQuery: jest.fn(), + editCourseUpdateQuery: jest.fn(), + editCourseHandoutsQuery: jest.fn(), +})); + +jest.mock('../constants', () => ({ + COMMA_SEPARATED_DATE_FORMAT: 'YYYY,MM,DD', +})); + +describe('handleUpdatesSubmit', () => { + let dispatchMock; + let closeUpdateFormMock; + let setCurrentUpdateMock; + + const courseId = 'course-v1:test+T101+2025_T1'; + + beforeEach(() => { + dispatchMock = jest.fn(); + closeUpdateFormMock = jest.fn(); + setCurrentUpdateMock = jest.fn(); + + jest.requireActual('./hooks'); + }); + + const getMockHookContext = (requestType) => { + jest.requireActual('../hooks'); + + return { + requestType, + courseId, + closeUpdateForm: closeUpdateFormMock, + setCurrentUpdate: setCurrentUpdateMock, + dispatch: dispatchMock, + initialUpdate: { id: 0, date: moment().toDate(), content: '' }, + }; + }; + + const testData = { + id: 5, + content: 'Sample content', + date: new Date('2025-08-01T00:00:00Z'), + }; + + it('dispatches createCourseUpdateQuery when requestType is add_new_update', () => { + const formattedDate = moment(testData.date).format(COMMA_SEPARATED_DATE_FORMAT); + createCourseUpdateQuery.mockReturnValue('mockCreateAction'); + + const context = getMockHookContext(REQUEST_TYPES.add_new_update); + const submitFn = (data) => { + const date = moment(data.date).format(COMMA_SEPARATED_DATE_FORMAT); + const action = createCourseUpdateQuery(context.courseId, { + date, + content: data.content, + }); + context.closeUpdateForm(); + context.setCurrentUpdate(context.initialUpdate); + context.dispatch(action); + }; + + submitFn(testData); + + expect(createCourseUpdateQuery).toHaveBeenCalledWith(courseId, { + date: formattedDate, + content: 'Sample content', + }); + expect(dispatchMock).toHaveBeenCalledWith('mockCreateAction'); + expect(closeUpdateFormMock).toHaveBeenCalled(); + expect(setCurrentUpdateMock).toHaveBeenCalledWith(expect.objectContaining({ id: 0 })); + }); + + it('dispatches editCourseUpdateQuery when requestType is edit_update', () => { + const formattedDate = moment(testData.date).format(COMMA_SEPARATED_DATE_FORMAT); + editCourseUpdateQuery.mockReturnValue('mockEditAction'); + + const context = getMockHookContext(REQUEST_TYPES.edit_update); + const submitFn = (data) => { + const date = moment(data.date).format(COMMA_SEPARATED_DATE_FORMAT); + const action = editCourseUpdateQuery(context.courseId, { + id: data.id, + date, + content: data.content, + }); + context.closeUpdateForm(); + context.setCurrentUpdate(context.initialUpdate); + context.dispatch(action); + }; + + submitFn(testData); + + expect(editCourseUpdateQuery).toHaveBeenCalledWith(courseId, { + id: 5, + date: formattedDate, + content: 'Sample content', + }); + expect(dispatchMock).toHaveBeenCalledWith('mockEditAction'); + expect(closeUpdateFormMock).toHaveBeenCalled(); + expect(setCurrentUpdateMock).toHaveBeenCalledWith(expect.objectContaining({ id: 0 })); + }); + + it('dispatches editCourseHandoutsQuery when requestType is edit_handouts', () => { + editCourseHandoutsQuery.mockReturnValue('mockHandoutAction'); + + const context = getMockHookContext(REQUEST_TYPES.edit_handouts); + const submitFn = (data) => { + const formatted = { + ...data, + date: moment(data.date).format(COMMA_SEPARATED_DATE_FORMAT), + data: data.data || '', + }; + const action = editCourseHandoutsQuery(context.courseId, formatted); + context.closeUpdateForm(); + context.setCurrentUpdate(context.initialUpdate); + context.dispatch(action); + }; + + submitFn(testData); + + expect(editCourseHandoutsQuery).toHaveBeenCalledWith(courseId, expect.objectContaining({ + date: expect.any(String), + content: 'Sample content', + })); + expect(dispatchMock).toHaveBeenCalledWith('mockHandoutAction'); + expect(closeUpdateFormMock).toHaveBeenCalled(); + expect(setCurrentUpdateMock).toHaveBeenCalledWith(expect.objectContaining({ id: 0 })); + }); + it('extracts date without formatting for internal logic', () => { + getMockHookContext(REQUEST_TYPES.edit_update); + + const submitFn = (data) => { + const dateWithoutTimezone = data.date; + expect(dateWithoutTimezone).toEqual(new Date('2025-08-01T00:00:00Z')); + }; + + submitFn(testData); + }); +}); diff --git a/src/course-updates/update-form/UpdateForm.jsx b/src/course-updates/update-form/UpdateForm.jsx index 23fa88321c..49c4060daa 100644 --- a/src/course-updates/update-form/UpdateForm.jsx +++ b/src/course-updates/update-form/UpdateForm.jsx @@ -13,8 +13,6 @@ import { Calendar as CalendarIcon, Error as ErrorIcon } from '@openedx/paragon/i import { Formik } from 'formik'; import { - convertToStringFromDate, - convertToDateFromString, isValidDate, } from '../../utils'; import { DATE_FORMAT, DEFAULT_EMPTY_WYSIWYG_VALUE } from '../../constants'; @@ -73,7 +71,7 @@ const UpdateForm = ({ diff --git a/src/generic/datepicker-control/DatepickerControl.jsx b/src/generic/datepicker-control/DatepickerControl.jsx index 91306f2f11..7c95769f67 100644 --- a/src/generic/datepicker-control/DatepickerControl.jsx +++ b/src/generic/datepicker-control/DatepickerControl.jsx @@ -6,7 +6,7 @@ import { Form, Icon } from '@openedx/paragon'; import { Calendar } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { convertToDateFromString, convertToStringFromDate, isValidDate } from '../../utils'; +import { isValidDate } from '../../utils'; import { DATE_FORMAT, TIME_FORMAT } from '../../constants'; import messages from './messages'; @@ -27,7 +27,7 @@ const DatepickerControl = ({ onChange, }) => { const intl = useIntl(); - const formattedDate = convertToDateFromString(value); + const formattedDate = value; const inputFormat = { [DATEPICKER_TYPES.date]: DATE_FORMAT, [DATEPICKER_TYPES.time]: TIME_FORMAT, @@ -69,7 +69,7 @@ const DatepickerControl = ({ showPopperArrow={false} onChange={(date) => { if (isValidDate(date)) { - onChange(convertToStringFromDate(date)); + onChange(date); } }} /> diff --git a/src/generic/datepicker-control/DatepickerControl.test.jsx b/src/generic/datepicker-control/DatepickerControl.test.jsx index 509fc0cf8b..2ff6d4fcdb 100644 --- a/src/generic/datepicker-control/DatepickerControl.test.jsx +++ b/src/generic/datepicker-control/DatepickerControl.test.jsx @@ -2,7 +2,6 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { convertToStringFromDate } from '../../utils'; import { DatepickerControl, DATEPICKER_TYPES } from '.'; import messages from './messages'; import { DATE_FORMAT } from '../../constants'; @@ -45,7 +44,7 @@ describe('', () => { const input = getByPlaceholderText(DATE_FORMAT.toLocaleUpperCase()); fireEvent.change(input, { target: { value: '06/16/2023' } }); expect(onChangeMock).toHaveBeenCalledWith( - convertToStringFromDate('06/16/2023'), + new Date('2023-06-16T00:00:00.000Z'), ); }); }); diff --git a/src/schedule-and-details/schedule-section/schedule-row/ScheduleRow.test.jsx b/src/schedule-and-details/schedule-section/schedule-row/ScheduleRow.test.jsx index 7535808182..c36ea763e1 100644 --- a/src/schedule-and-details/schedule-section/schedule-row/ScheduleRow.test.jsx +++ b/src/schedule-and-details/schedule-section/schedule-row/ScheduleRow.test.jsx @@ -37,7 +37,7 @@ describe('', () => { const input = getByPlaceholderText('MM/DD/YYYY'); fireEvent.change(input, { target: { value: '06/15/2023' } }); expect(onChangeMock).toHaveBeenCalledWith( - '2023-06-15T00:00:00Z', + new Date('2023-06-15T00:00:00Z'), props.controlName, ); });