From 499353c68b73bb728b7621cbf34025cd198f4dc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Jim=C3=A9nez?= Date: Wed, 18 Dec 2024 17:01:22 -0500 Subject: [PATCH] feat: event modal for instructor detail page --- .../__test__/index.test.jsx | 25 +++++- .../InstructorsDetailPage/index.jsx | 84 ++++++++++++++++--- src/features/Instructors/data/api.js | 9 ++ src/features/Instructors/data/index.js | 3 +- src/features/constants.js | 10 +++ src/helpers/__test__/index.test.js | 46 ++++++++++ src/helpers/index.js | 28 +++++++ 7 files changed, 190 insertions(+), 15 deletions(-) diff --git a/src/features/Instructors/InstructorsDetailPage/__test__/index.test.jsx b/src/features/Instructors/InstructorsDetailPage/__test__/index.test.jsx index bd747bc7..5698fa22 100644 --- a/src/features/Instructors/InstructorsDetailPage/__test__/index.test.jsx +++ b/src/features/Instructors/InstructorsDetailPage/__test__/index.test.jsx @@ -121,7 +121,7 @@ describe('InstructorsDetailPage', () => { test('Should render the calendar if the flag is provided', async () => { getConfig.mockImplementation(() => ({ enable_instructor_calendar: true })); - const { getByText } = renderWithProviders( + const { getByText, getAllByText } = renderWithProviders( @@ -130,7 +130,7 @@ describe('InstructorsDetailPage', () => { { preloadedState: mockStore }, ); - fireEvent.click(getByText('Availability')); + fireEvent.click(getAllByText('Availability')[0]); await waitFor(() => { expect(getByText('Today')).toBeInTheDocument(); @@ -160,4 +160,25 @@ describe('InstructorsDetailPage', () => { expect(calendarTab).not.toBeInTheDocument(); }); + + test('Should render event modal', () => { + getConfig.mockImplementation(() => ({ enable_instructor_calendar: true })); + + const { getByText, getAllByText } = renderWithProviders( + + + + + , + { preloadedState: mockStore }, + ); + + const newEventBtn = getByText('New event'); + fireEvent.click(newEventBtn); + expect(getAllByText('New event')).toHaveLength(2); + + waitFor(() => { + expect(getByText('New Event')).toBeInTheDocument(); + }); + }); }); diff --git a/src/features/Instructors/InstructorsDetailPage/index.jsx b/src/features/Instructors/InstructorsDetailPage/index.jsx index e64c2737..297b651c 100644 --- a/src/features/Instructors/InstructorsDetailPage/index.jsx +++ b/src/features/Instructors/InstructorsDetailPage/index.jsx @@ -13,17 +13,25 @@ import { Tab, } from '@edx/paragon'; import { getConfig } from '@edx/frontend-platform'; -import { CalendarExpanded } from 'react-paragon-topaz'; -import { startOfMonth, endOfMonth } from 'date-fns'; +import { startOfMonth, endOfMonth, endOfDay } from 'date-fns'; +import { logError } from '@edx/frontend-platform/logging'; +import { CalendarExpanded, Button, AddEventModal } from 'react-paragon-topaz'; import Table from 'features/Main/Table'; import { fetchClassesData } from 'features/Classes/data/thunks'; import { resetClassesTable, updateCurrentPage } from 'features/Classes/data/slice'; -import { fetchInstructorsData, fetchEventsData, resetEvents } from 'features/Instructors/data'; +import { + fetchInstructorsData, + fetchEventsData, + resetEvents, + updateEvents, +} from 'features/Instructors/data'; +import { createInstructorEvent } from 'features/Instructors/data/api'; import { columns } from 'features/Instructors/InstructorDetailTable/columns'; -import { initialPage, RequestStatus } from 'features/constants'; +import { initialPage, RequestStatus, AVAILABILITY_VALUES } from 'features/constants'; import { useInstitutionIdQueryParam } from 'hooks'; +import { setTimeInUTC, stringToDateType } from 'helpers'; import LinkWithQuery from 'features/Main/LinkWithQuery'; @@ -40,25 +48,33 @@ const defaultInstructorInfo = { instructorName: '', }; +const generateValueLabelPairs = (options) => options.reduce((accumulator, option) => { + accumulator[option.value] = option.label; + return accumulator; +}, {}); + +const typeEventOptions = [ + { label: 'Not available', value: AVAILABILITY_VALUES.notAvailable }, + { label: 'Available', value: AVAILABILITY_VALUES.available }, + { label: 'Prep Time', value: AVAILABILITY_VALUES.prepTime }, +]; + +const eventTitles = generateValueLabelPairs(typeEventOptions); + const InstructorsDetailPage = () => { const history = useHistory(); const dispatch = useDispatch(); const { instructorUsername } = useParams(); + const addQueryParam = useInstitutionIdQueryParam(); const events = useSelector((state) => state.instructors.events.data); const institutionRef = useRef(undefined); - const [currentPage, setCurrentPage] = useState(initialPage); const [eventsList, setEventsList] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [currentPage, setCurrentPage] = useState(initialPage); const [rangeDates, setRangeDates] = useState(initialState); - const getRangeDate = useCallback((range) => { - setRangeDates({ - start_date: range.start.toISOString(), - end_date: range.end.toISOString(), - }); - }, [setRangeDates]); - const institution = useSelector((state) => state.main.selectedInstitution); const classes = useSelector((state) => state.classes.table); const instructorInfo = useSelector((state) => state.instructors.table.data) @@ -72,6 +88,38 @@ const InstructorsDetailPage = () => { dispatch(updateCurrentPage(targetPage)); }; + const handleToggleModal = () => setIsModalOpen(!isModalOpen); + + const getRangeDate = useCallback((range) => { + setRangeDates({ + start_date: range.start.toISOString(), + end_date: range.end.toISOString(), + }); + }, [setRangeDates]); + + const createNewEvent = async (eventData) => { + try { + const endTypeDate = stringToDateType(eventData.endDate); + const eventDataRequest = { + instructor_id: instructorInfo.instructorId, + title: eventTitles[eventData.availability || 'available'], + availability: eventData.availability || 'available', + start: setTimeInUTC(stringToDateType(eventData.startDate), eventData.startHour), + end: setTimeInUTC(endOfDay(endTypeDate), eventData.endHour), + recurrence: eventData.recurrence.value, + }; + + const { data: newEvent } = await createInstructorEvent(eventDataRequest); + dispatch(updateEvents([...events, newEvent])); + + if (eventDataRequest.recurrence) { + dispatch(fetchEventsData(rangeDates, events)); + } + } catch (error) { + logError(error); + } + }; + useEffect(() => { if (institution.id) { dispatch(fetchInstructorsData(institution.id, initialPage, { instructor: instructorUsername })); @@ -155,6 +203,18 @@ const InstructorsDetailPage = () => { { showInstructorCalendar && ( + +
+

Availability

+ +
{ expect(window.open).toHaveBeenCalledWith(url, '_blank', 'noopener,noreferrer'); }); }); + +describe('stringToDateType', () => { + test('Should convert a string date in YYYY-MM-DD format to a Date object', () => { + const input = '2024-12-18'; + const result = stringToDateType(input); + + expect(result instanceof Date).toBe(true); + + expect(result.getFullYear()).toBe(2024); + expect(result.getMonth()).toBe(11); + expect(result.getDate()).toBe(18); + }); + + test('Should handle invalid date strings gracefully', () => { + const input = 'invalid-date'; + const result = stringToDateType(input); + + expect(result.toString()).toBe('Invalid Date'); + }); +}); + +describe('setTimeInUTC', () => { + test('Should set time correctly with provided time string', () => { + const date = '2024-09-17T00:00:00Z'; + const timeString = '15:37'; + const result = setTimeInUTC(date, timeString); + + expect(result).toBe('2024-09-17T15:37:00.000Z'); + }); + + test('Should maintain original time if no time string is provided', () => { + const date = '2024-09-17T12:34:56Z'; + const result = setTimeInUTC(date); + expect(result).toBe('2024-09-17T12:34:56.000Z'); + }); + + test('Should handle edge case of date string input', () => { + const date = new Date('2024-09-17'); + const timeString = '15:37'; + const result = setTimeInUTC(date, timeString); + + expect(result).toBe('2024-09-17T15:37:00.000Z'); + }); +}); diff --git a/src/helpers/index.js b/src/helpers/index.js index 9a3bd867..9e995b9a 100644 --- a/src/helpers/index.js +++ b/src/helpers/index.js @@ -153,3 +153,31 @@ export const formatSelectOptions = (options) => { value: option.id, })); }; + +/** + * Sets the time of a given date in UTC format. If no time is provided, the current time of the date is maintained. + * + * @param {Date | string} date - The date object or date string to modify. + * @param {string} [timeString] - Optional time in the format 'HH:MM'. If provided, it sets the hours and minutes. + * @returns {string} - The date in ISO string format with the time adjusted to UTC. + */ +export const setTimeInUTC = (date, timeString) => { + const newDate = new Date(date); + + if (timeString) { + const [hours, minutes] = timeString.split(':').map(Number); + newDate.setUTCHours(hours); + newDate.setUTCMinutes(minutes); + newDate.setSeconds(0); + } + + return newDate?.toISOString(); +}; + +/** + * Transform string to date type + * + * @param {string} date - The string with format 'YYYY-MM-DD' to transform + * @returns {Date} - The Date in format 'YYYY/MM/DD' + */ +export const stringToDateType = (date) => new Date(date.replace(/-/g, '/'));