diff --git a/src/features/Instructors/InstructorsDetailPage/_test_/index.test.jsx b/src/features/Instructors/InstructorsDetailPage/__test__/index.test.jsx similarity index 59% rename from src/features/Instructors/InstructorsDetailPage/_test_/index.test.jsx rename to src/features/Instructors/InstructorsDetailPage/__test__/index.test.jsx index be873313..bd747bc7 100644 --- a/src/features/Instructors/InstructorsDetailPage/_test_/index.test.jsx +++ b/src/features/Instructors/InstructorsDetailPage/__test__/index.test.jsx @@ -1,15 +1,20 @@ import React from 'react'; -import { waitFor } from '@testing-library/react'; -import '@testing-library/jest-dom/extend-expect'; -import { renderWithProviders } from 'test-utils'; +import { fireEvent, waitFor } from '@testing-library/react'; import { MemoryRouter, Route } from 'react-router-dom'; +import { getConfig } from '@edx/frontend-platform'; +import '@testing-library/jest-dom/extend-expect'; +import { renderWithProviders } from 'test-utils'; import InstructorsDetailPage from 'features/Instructors/InstructorsDetailPage'; jest.mock('@edx/frontend-platform/logging', () => ({ logError: jest.fn(), })); +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(), +})); + const mockStore = { main: { selectedInstitution: { @@ -69,6 +74,20 @@ const mockStore = { num_pages: 1, current_page: 1, }, + events: { + data: [ + { + id: 1, + title: 'Not available', + start: '2024-09-04T00:00:00Z', + end: '2024-09-13T00:00:00Z', + type: 'virtual', + }, + ], + count: 1, + num_pages: 1, + current_page: 1, + }, }, }; @@ -98,4 +117,47 @@ describe('InstructorsDetailPage', () => { expect(component.container).toHaveTextContent('in progress'); }); }); + + test('Should render the calendar if the flag is provided', async () => { + getConfig.mockImplementation(() => ({ enable_instructor_calendar: true })); + + const { getByText } = renderWithProviders( + + + + + , + { preloadedState: mockStore }, + ); + + fireEvent.click(getByText('Availability')); + + await waitFor(() => { + expect(getByText('Today')).toBeInTheDocument(); + expect(getByText('Sunday')).toBeInTheDocument(); + expect(getByText('Monday')).toBeInTheDocument(); + expect(getByText('Tuesday')).toBeInTheDocument(); + expect(getByText('Wednesday')).toBeInTheDocument(); + expect(getByText('Thursday')).toBeInTheDocument(); + expect(getByText('Friday')).toBeInTheDocument(); + expect(getByText('Saturday')).toBeInTheDocument(); + }); + }); + + test('Should not render the calendar if the flag is false or is not provided', async () => { + getConfig.mockImplementation(() => ({ enable_instructor_calendar: false })); + + const { queryByText } = renderWithProviders( + + + + + , + { preloadedState: mockStore }, + ); + + const calendarTab = queryByText('Availability'); + + expect(calendarTab).not.toBeInTheDocument(); + }); }); diff --git a/src/features/Instructors/InstructorsDetailPage/index.jsx b/src/features/Instructors/InstructorsDetailPage/index.jsx index 1b3db6f5..30f640b2 100644 --- a/src/features/Instructors/InstructorsDetailPage/index.jsx +++ b/src/features/Instructors/InstructorsDetailPage/index.jsx @@ -1,4 +1,9 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { + useState, + useEffect, + useRef, + useCallback, +} from 'react'; import { useParams, useHistory } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import { @@ -7,10 +12,14 @@ import { Tabs, Tab, } from '@edx/paragon'; +import { getConfig } from '@edx/frontend-platform'; +import { CalendarExpanded } from 'react-paragon-topaz'; +import { startOfMonth, endOfMonth } from 'date-fns'; + import Table from 'features/Main/Table'; import { fetchClassesData } from 'features/Classes/data/thunks'; import { resetClassesTable, updateCurrentPage } from 'features/Classes/data/slice'; -import { fetchInstructorsData } from 'features/Instructors/data/thunks'; +import { fetchInstructorsData, fetchEventsData, resetEvents } from 'features/Instructors/data'; import { columns } from 'features/Instructors/InstructorDetailTable/columns'; import { initialPage, RequestStatus } from 'features/constants'; @@ -18,25 +27,45 @@ import { useInstitutionIdQueryParam } from 'hooks'; import LinkWithQuery from 'features/Main/LinkWithQuery'; +import 'features/Instructors/InstructorsDetailPage/index.scss'; + +const initialState = { + instructor_id: null, + start_date: startOfMonth(new Date()).toISOString(), + end_date: endOfMonth(new Date()).toISOString(), +}; + +const defaultInstructorInfo = { + instructorUsername: '', + instructorName: '', +}; + 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 [rangeDates, setRangeDates] = useState(initialState); - const defaultInstructorInfo = { - instructorUsername: '', - instructorName: '', - }; + 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) - .find((instructor) => instructor?.instructorUsername === instructorUsername) || defaultInstructorInfo; + ?.find((instructor) => instructor?.instructorUsername === instructorUsername) || defaultInstructorInfo; + const isLoading = classes.status === RequestStatus.LOADING; + const showInstructorCalendar = getConfig().enable_instructor_calendar || false; const handlePagination = (targetPage) => { setCurrentPage(targetPage); @@ -44,19 +73,33 @@ const InstructorsDetailPage = () => { }; useEffect(() => { - if (institution.id && instructorUsername) { - dispatch(fetchClassesData(institution.id, initialPage, '', { instructor: instructorUsername })); + if (institution.id) { dispatch(fetchInstructorsData(institution.id, initialPage, { instructor: instructorUsername })); } + }, [dispatch, institution.id, instructorUsername]); + + useEffect(() => { + if (institution.id) { + dispatch(fetchClassesData(institution.id, currentPage, '', { instructor: instructorUsername })); + } return () => { dispatch(resetClassesTable()); }; - }, [dispatch, institution.id, instructorUsername]); + }, [dispatch, institution.id, currentPage, instructorUsername]); useEffect(() => { - dispatch(fetchClassesData(institution.id, currentPage, '', { instructor: instructorUsername })); - }, [currentPage]); // eslint-disable-line react-hooks/exhaustive-deps + if (instructorInfo.instructorId && showInstructorCalendar) { + dispatch(fetchEventsData({ + ...rangeDates, + instructor_id: instructorInfo.instructorId, + })); + } + + return () => { + dispatch(resetEvents()); + }; + }, [dispatch, instructorInfo.instructorId, rangeDates, showInstructorCalendar]); useEffect(() => { if (institution.id !== undefined && institutionRef.current === undefined) { @@ -66,8 +109,19 @@ const InstructorsDetailPage = () => { if (institution.id !== institutionRef.current) { history.push(addQueryParam('/instructors')); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [institution, history]); + }, [institution, history, addQueryParam]); + + useEffect(() => { + if (events.length > 0) { + const list = events?.map(event => ({ + ...event, + start: new Date(event.start), + end: new Date(event.end), + })); + + setEventsList(list); + } + }, [events]); return ( @@ -81,27 +135,43 @@ const InstructorsDetailPage = () => { - + + + {classes.numPages > initialPage && ( + + )} + + { + showInstructorCalendar && ( + + + {}} + onDelete={() => {}} + onDeleteMultiple={() => {}} + onEditSinglRec={() => {}} + /> + + + ) + } - - - {classes.numPages > initialPage && ( - - )} ); }; diff --git a/src/features/Instructors/InstructorsDetailPage/index.scss b/src/features/Instructors/InstructorsDetailPage/index.scss new file mode 100644 index 00000000..1fdab58a --- /dev/null +++ b/src/features/Instructors/InstructorsDetailPage/index.scss @@ -0,0 +1,5 @@ +@import 'assets/colors.scss'; + +.container-calendar { + box-shadow: 0px 3px 12px 0px $gray-30; +} diff --git a/src/features/Instructors/data/_test_/api.test.js b/src/features/Instructors/data/__test__/api.test.js similarity index 69% rename from src/features/Instructors/data/_test_/api.test.js rename to src/features/Instructors/data/__test__/api.test.js index 999278f4..364aeb6e 100644 --- a/src/features/Instructors/data/_test_/api.test.js +++ b/src/features/Instructors/data/__test__/api.test.js @@ -2,6 +2,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { handleInstructorsEnrollment, handleNewInstructor, + getEventsByInstructor, } from 'features/Instructors/data/api'; jest.mock('@edx/frontend-platform/auth', () => ({ @@ -66,4 +67,37 @@ describe('should call getAuthenticatedHttpClient with the correct parameters', ( + '?institution_id=1&instructor_email=instructor%40example.com&first_name=Sam&last_name=F', ); }); + + test('getEventsByInstructor', () => { + const httpClientMock = { + get: jest.fn(), + }; + + const eventRequestInfo = { + instructor_id: '3', + start_date: '2024-12-01T05:00:00.000Z', + end_date: '2025-01-01T04:59:59.999Z', + page: '1', + }; + + getAuthenticatedHttpClient.mockReturnValue(httpClientMock); + + getEventsByInstructor({ ...eventRequestInfo }); + + expect(getAuthenticatedHttpClient).toHaveBeenCalledTimes(1); + expect(getAuthenticatedHttpClient).toHaveBeenCalledWith(); + + expect(httpClientMock.get).toHaveBeenCalledTimes(1); + expect(httpClientMock.get).toHaveBeenCalledWith( + 'http://localhost:18000/pearson_course_operation/api/v2/events/', + { + params: { + instructor_id: '3', + start_date: '2024-12-01T05:00:00.000Z', + end_date: '2025-01-01T04:59:59.999Z', + page: '1', + }, + }, + ); + }); }); diff --git a/src/features/Instructors/data/_test_/redux.test.js b/src/features/Instructors/data/__test__/redux.test.js similarity index 100% rename from src/features/Instructors/data/_test_/redux.test.js rename to src/features/Instructors/data/__test__/redux.test.js diff --git a/src/features/Instructors/data/api.js b/src/features/Instructors/data/api.js index 9f24b46b..f00b3097 100644 --- a/src/features/Instructors/data/api.js +++ b/src/features/Instructors/data/api.js @@ -17,7 +17,22 @@ function handleNewInstructor(institutionId, instructorFormData) { return getAuthenticatedHttpClient().post(`${apiV2BaseUrl}&${searchParams}`); } +/** + * Get events list by instructor. + * + * @param {object} - An object with the start and end date range for the calendar + * Dates in format ISO + * @returns {Promise} - A promise that resolves with the response of the GET request. + */ +function getEventsByInstructor(params) { + return getAuthenticatedHttpClient().get( + `${getConfig().COURSE_OPERATIONS_API_V2_BASE_URL}/events/`, + { params }, + ); +} + export { handleInstructorsEnrollment, handleNewInstructor, + getEventsByInstructor, }; diff --git a/src/features/Instructors/data/index.js b/src/features/Instructors/data/index.js index c647f996..40f3ba68 100644 --- a/src/features/Instructors/data/index.js +++ b/src/features/Instructors/data/index.js @@ -1,4 +1,7 @@ -export { reducer } from 'features/Instructors/data/slice'; +export { reducer, resetEvents } from 'features/Instructors/data/slice'; export { - fetchInstructorsData, assignInstructors, fetchInstructorsOptionsData, + fetchInstructorsData, + assignInstructors, + fetchInstructorsOptionsData, + fetchEventsData, } from 'features/Instructors/data/thunks'; diff --git a/src/features/Instructors/data/slice.js b/src/features/Instructors/data/slice.js index 7a436f21..090a28ec 100644 --- a/src/features/Instructors/data/slice.js +++ b/src/features/Instructors/data/slice.js @@ -1,5 +1,7 @@ /* eslint-disable no-param-reassign */ import { createSlice } from '@reduxjs/toolkit'; +import { startOfMonth, endOfMonth } from 'date-fns'; + import { RequestStatus } from 'features/constants'; const initialState = { @@ -29,6 +31,14 @@ const initialState = { status: RequestStatus.LOADING, data: [], }, + events: { + data: [], + status: RequestStatus.INITIAL, + dates: { + start_date: startOfMonth(new Date()).toISOString(), + end_date: endOfMonth(new Date()).toISOString(), + }, + }, }; export const instructorsSlice = createSlice({ @@ -100,11 +110,23 @@ export const instructorsSlice = createSlice({ state.selectOptions.status = RequestStatus.LOADING; state.selectOptions.data = []; }, + updateEvents: (state, { payload }) => { + state.events.data = payload; + }, + resetEvents: (state) => { + state.events = initialState.events; + }, + updateEventsRequestStatus: (state, { payload }) => { + state.events.status = payload; + }, }, }); export const { + resetEvents, + updateEvents, updateCurrentPage, + updateEventsRequestStatus, fetchInstructorsDataRequest, fetchInstructorsDataSuccess, fetchInstructorsDataFailed, diff --git a/src/features/Instructors/data/thunks.js b/src/features/Instructors/data/thunks.js index db608b03..0bb50231 100644 --- a/src/features/Instructors/data/thunks.js +++ b/src/features/Instructors/data/thunks.js @@ -1,14 +1,16 @@ import { logError } from '@edx/frontend-platform/logging'; import { camelCaseObject } from '@edx/frontend-platform'; -import { handleInstructorsEnrollment, handleNewInstructor } from 'features/Instructors/data/api'; +import { handleInstructorsEnrollment, handleNewInstructor, getEventsByInstructor } from 'features/Instructors/data/api'; import { getInstructorByInstitution } from 'features/Common/data/api'; import { + updateEvents, fetchInstructorsDataRequest, fetchInstructorsDataSuccess, fetchInstructorsDataFailed, assignInstructorsRequest, assignInstructorsSuccess, assignInstructorsFailed, + updateEventsRequestStatus, updateInstructorAdditionRequest, fetchInstructorOptionsRequest, fetchInstructorOptionsSuccess, @@ -92,9 +94,56 @@ function addInstructor(institutionId, instructorFormData) { }; } +function fetchEventsData(eventData, currentEvents = []) { + return async (dispatch) => { + dispatch(updateEventsRequestStatus(RequestStatus.LOADING)); + + let allEvents = currentEvents; + let page = 1; + + const fetchAllPages = async () => { + try { + const response = camelCaseObject(await getEventsByInstructor({ ...eventData, page })); + const { results, next: existNextPage } = response.data; + + /* Filters out duplicate events from `results` based on `id`, `start`, and `end` properties + when compared to `allEvents`. For the remaining unique events, adds a unique `elementId` + by combining the current timestamp and a random number. */ + const uniqueResults = results + .filter(newEvent => !allEvents.some(existingEvent => existingEvent.id === newEvent.id + && existingEvent.start === newEvent.start + && existingEvent.end === newEvent.end)) + .map(newEvent => ({ + ...newEvent, + elementId: `${Date.now()}_${Math.floor(Math.random() * 1e6)}`, + })); + + allEvents = [...allEvents, ...uniqueResults]; + dispatch(updateEvents(allEvents)); + + if (existNextPage) { + page += 1; + // eslint-disable-next-line no-promise-executor-return + await new Promise((resolve) => setTimeout(resolve, 200)); + return fetchAllPages(); + } + + dispatch(updateEventsRequestStatus(RequestStatus.SUCCESS)); + return allEvents; + } catch (error) { + dispatch(updateEventsRequestStatus(RequestStatus.ERROR)); + return logError(error); + } + }; + + return fetchAllPages(); + }; +} + export { fetchInstructorsData, assignInstructors, addInstructor, + fetchEventsData, fetchInstructorsOptionsData, };