diff --git a/src/app.scss b/src/app.scss index 55493494..4b059ab0 100644 --- a/src/app.scss +++ b/src/app.scss @@ -4,3 +4,7 @@ left: unset; right: var(--pgn-spacing-toast-container-gutter-lg); } + +.text-prewrap { + white-space: pre-wrap; +} diff --git a/src/components/ObjectCell.tsx b/src/components/ObjectCell.tsx new file mode 100644 index 00000000..9a02dd47 --- /dev/null +++ b/src/components/ObjectCell.tsx @@ -0,0 +1,15 @@ +import { parseObject } from '@src/utils/formatters'; + +interface ObjectCellProps { + value: Record | null, +} + +const ObjectCell = ({ value }: ObjectCellProps) => { + return ( +
+      {parseObject(value ?? {})}
+    
+ ); +}; + +export { ObjectCell }; diff --git a/src/components/PendingTasks.test.tsx b/src/components/PendingTasks.test.tsx new file mode 100644 index 00000000..6ccc2d47 --- /dev/null +++ b/src/components/PendingTasks.test.tsx @@ -0,0 +1,98 @@ +import { screen } from '@testing-library/react'; +import { PendingTasks } from './PendingTasks'; +import { usePendingTasks } from '@src/data/apiHook'; +import { renderWithQueryClient } from '@src/testUtils'; + +jest.mock('@src/data/apiHook'); + +const mockUsePendingTasks = usePendingTasks as jest.MockedFunction; + +describe('PendingTasks', () => { + const mockFetchTasks = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUsePendingTasks.mockReturnValue({ + data: undefined, + isPending: false, + isLoading: false, + } as any); + }); + + it('should render the collapsible pending tasks section', () => { + renderWithQueryClient(); + + expect(screen.getByText('Pending Tasks')).toBeInTheDocument(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('should show loading skeleton when tasks are being fetched', async () => { + mockUsePendingTasks.mockReturnValue({ + data: undefined, + isPending: true, + isLoading: true, + } as any); + + const { container } = renderWithQueryClient(); + const toggleButton = screen.getByRole('button'); + await toggleButton.click(); + + expect(screen.queryByText('No tasks currently running.')).not.toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + expect(screen.queryByText('Task Type')).not.toBeInTheDocument(); + + const skeletons = container.querySelectorAll('.react-loading-skeleton'); + expect(skeletons).toHaveLength(3); + }); + + it('should display no tasks message when tasks array is empty', async () => { + mockUsePendingTasks.mockReturnValue({ + mutate: mockFetchTasks, + data: [], + isPending: false, + } as any); + + renderWithQueryClient(); + const toggleButton = screen.getByRole('button'); + await toggleButton.click(); + + expect(screen.getByText('No tasks currently running.')).toBeInTheDocument(); + }); + + it('should render data table with tasks when data is available', async () => { + const mockTasks = [ + { + taskType: 'grade_course', + taskInput: 'course data', + taskId: '12345', + requester: 'instructor@example.com', + taskState: 'SUCCESS', + created: '2023-01-01', + taskOutput: 'output.csv', + duration: '5 minutes', + status: 'Completed', + taskMessage: 'Task completed successfully', + }, + ]; + + mockUsePendingTasks.mockReturnValue({ + data: mockTasks, + isPending: false, + isLoading: false, + } as any); + + renderWithQueryClient(); + const toggleButton = screen.getByRole('button'); + await toggleButton.click(); + + expect(screen.getByText('Task Type')).toBeInTheDocument(); + expect(screen.getByText('Task ID')).toBeInTheDocument(); + expect(screen.getByText('grade_course')).toBeInTheDocument(); + expect(screen.getByText('12345')).toBeInTheDocument(); + }); + + it('should fetch tasks on component mount', async () => { + renderWithQueryClient(); + expect(mockUsePendingTasks).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/PendingTasks.tsx b/src/components/PendingTasks.tsx new file mode 100644 index 00000000..aff540bf --- /dev/null +++ b/src/components/PendingTasks.tsx @@ -0,0 +1,81 @@ +import { useIntl } from '@openedx/frontend-base'; +import { Collapsible, DataTable, Icon, Skeleton } from '@openedx/paragon'; +import { useMemo } from 'react'; +import messages from './messages'; +import { ExpandLess, ExpandMore } from '@openedx/paragon/icons'; +import { usePendingTasks } from '@src/data/apiHook'; +import { useParams } from 'react-router'; +import { ObjectCell } from './ObjectCell'; +import { PendingTask, TableCellValue } from '@src/types'; + +interface PendingTasksProps { + isPolling?: boolean, +} + +const PendingTasks = ({ isPolling = false }: PendingTasksProps) => { + const intl = useIntl(); + const { courseId = '' } = useParams(); + const { data: tasks, isLoading } = usePendingTasks(courseId, { enablePolling: isPolling }); + + const tableColumns = useMemo(() => [ + { accessor: 'taskType', Header: intl.formatMessage(messages.taskTypeColumnName) }, + { accessor: 'taskInput', Header: intl.formatMessage(messages.taskInputColumnName), Cell: ({ row }: TableCellValue) => }, + { accessor: 'taskId', Header: intl.formatMessage(messages.taskIdColumnName) }, + { accessor: 'requester', Header: intl.formatMessage(messages.requesterColumnName) }, + { accessor: 'taskState', Header: intl.formatMessage(messages.taskStateColumnName) }, + { accessor: 'created', Header: intl.formatMessage(messages.createdColumnName) }, + { accessor: 'taskOutput', Header: intl.formatMessage(messages.taskOutputColumnName), Cell: ({ row }: TableCellValue) => }, + { accessor: 'durationSec', Header: intl.formatMessage(messages.durationColumnName) }, + { accessor: 'status', Header: intl.formatMessage(messages.statusColumnName) }, + { accessor: 'taskMessage', Header: intl.formatMessage(messages.taskMessageColumnName) }, + ], [intl]); + + const renderContent = () => { + if (isLoading) { + return ; + } + + if (!tasks || tasks?.length === 0) { + return
{intl.formatMessage(messages.noTasksMessage)}
; + } + + return ( + null} + /> + ); + }; + + return ( + + +
+

{intl.formatMessage(messages.pendingTasksTitle)}

+
+ + +
+ +
+
+ +
+ +
+
+
+ + {renderContent() } + +
+ ); +}; + +export { PendingTasks }; diff --git a/src/components/messages.ts b/src/components/messages.ts index a0769908..8397dd7c 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -26,6 +26,66 @@ const messages = defineMessages({ defaultMessage: "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.", description: 'Body text for page not found error', }, + pendingTasksTitle: { + id: 'instruct.pendingTasks.section.title', + defaultMessage: 'Pending Tasks', + description: 'Title for the pending tasks section', + }, + noTasksMessage: { + id: 'instruct.pendingTasks.section.noTasks', + defaultMessage: 'No tasks currently running.', + description: 'Message displayed when there are no pending tasks', + }, + taskTypeColumnName: { + id: 'instruct.pendingTasks.table.column.taskType', + defaultMessage: 'Task Type', + description: 'Column name for task type in pending tasks table', + }, + taskInputColumnName: { + id: 'instruct.pendingTasks.table.column.taskInput', + defaultMessage: 'Task Input', + description: 'Column name for task input in pending tasks table', + }, + taskIdColumnName: { + id: 'instruct.pendingTasks.table.column.taskId', + defaultMessage: 'Task ID', + description: 'Column name for task ID in pending tasks table', + }, + requesterColumnName: { + id: 'instruct.pendingTasks.table.column.requester', + defaultMessage: 'Requester', + description: 'Column name for requester in pending tasks table', + }, + taskStateColumnName: { + id: 'instruct.pendingTasks.table.column.taskState', + defaultMessage: 'Task State', + description: 'Column name for task state in pending tasks table', + }, + createdColumnName: { + id: 'instruct.pendingTasks.table.column.created', + defaultMessage: 'Created', + description: 'Column name for created date in pending tasks table', + }, + taskOutputColumnName: { + id: 'instruct.pendingTasks.table.column.taskOutput', + defaultMessage: 'Task Output', + description: 'Column name for task output in pending tasks table', + }, + durationColumnName: { + id: 'instruct.pendingTasks.table.column.duration', + defaultMessage: 'Duration (sec)', + description: 'Column name for duration in pending tasks table', + }, + statusColumnName: { + id: 'instruct.pendingTasks.table.column.status', + defaultMessage: 'Status', + description: 'Column name for status in pending tasks table', + }, + taskMessageColumnName: { + id: 'instruct.pendingTasks.table.column.taskMessage', + defaultMessage: 'Task Message', + description: 'Column name for task message in pending tasks table', + }, }); export default messages; diff --git a/src/data/api.test.ts b/src/data/api.test.ts index ac7f6011..0368efd5 100644 --- a/src/data/api.test.ts +++ b/src/data/api.test.ts @@ -1,42 +1,86 @@ import { getCourseInfo } from './api'; import { camelCaseObject, getAppConfig, getAuthenticatedHttpClient } from '@openedx/frontend-base'; +import { fetchPendingTasks } from './api'; -jest.mock('@openedx/frontend-base'); - -const mockHttpClient = { - get: jest.fn(), - put: jest.fn(), -}; +jest.mock('@openedx/frontend-base', () => ({ + ...jest.requireActual('@openedx/frontend-base'), + camelCaseObject: jest.fn((obj) => obj), + getAppConfig: jest.fn(), + getAuthenticatedHttpClient: jest.fn(), +})); const mockGetAppConfig = getAppConfig as jest.MockedFunction; const mockGetAuthenticatedHttpClient = getAuthenticatedHttpClient as jest.MockedFunction; const mockCamelCaseObject = camelCaseObject as jest.MockedFunction; -describe('getCourseInfo', () => { - const mockCourseData = { course_name: 'Test Course' }; - const mockCamelCaseData = { courseName: 'Test Course' }; - - beforeEach(() => { - jest.clearAllMocks(); - mockGetAppConfig.mockReturnValue({ LMS_BASE_URL: 'https://test-lms.com' }); - mockGetAuthenticatedHttpClient.mockReturnValue(mockHttpClient as any); - mockCamelCaseObject.mockReturnValue(mockCamelCaseData); - mockHttpClient.get.mockResolvedValue({ data: mockCourseData }); +describe('base api', () => { + afterEach(() => { + jest.resetAllMocks(); }); - it('fetches course info successfully', async () => { - const courseId = 'test-course-123'; - const result = await getCourseInfo(courseId); - expect(mockGetAppConfig).toHaveBeenCalledWith('org.openedx.frontend.app.instructor'); - expect(mockGetAuthenticatedHttpClient).toHaveBeenCalled(); - expect(mockHttpClient.get).toHaveBeenCalledWith('https://test-lms.com/api/instructor/v2/courses/test-course-123'); - expect(mockCamelCaseObject).toHaveBeenCalledWith(mockCourseData); - expect(result).toBe(mockCamelCaseData); + describe('getCourseInfo', () => { + const mockHttpClient = { + get: jest.fn(), + }; + const mockCourseData = { course_name: 'Test Course' }; + const mockCamelCaseData = { courseName: 'Test Course' }; + + beforeEach(() => { + mockGetAppConfig.mockReturnValue({ LMS_BASE_URL: 'https://test-lms.com' }); + mockGetAuthenticatedHttpClient.mockReturnValue(mockHttpClient as any); + mockCamelCaseObject.mockReturnValue(mockCamelCaseData); + mockHttpClient.get.mockResolvedValue({ data: mockCourseData }); + }); + + it('fetches course info successfully', async () => { + const courseId = 'test-course-123'; + const result = await getCourseInfo(courseId); + expect(mockGetAppConfig).toHaveBeenCalledWith('org.openedx.frontend.app.instructor'); + expect(mockGetAuthenticatedHttpClient).toHaveBeenCalled(); + expect(mockHttpClient.get).toHaveBeenCalledWith('https://test-lms.com/api/instructor/v2/courses/test-course-123'); + expect(mockCamelCaseObject).toHaveBeenCalledWith(mockCourseData); + expect(result).toBe(mockCamelCaseData); + }); + + it('throws error when API call fails', async () => { + const error = new Error('Network error'); + mockHttpClient.get.mockRejectedValue(error); + await expect(getCourseInfo('test-course')).rejects.toThrow('Network error'); + }); }); - it('throws error when API call fails', async () => { - const error = new Error('Network error'); - mockHttpClient.get.mockRejectedValue(error); - await expect(getCourseInfo('test-course')).rejects.toThrow('Network error'); + describe('fetchPendingTasks', () => { + const mockHttpClient = { + post: jest.fn(), + }; + + beforeEach(() => { + mockCamelCaseObject.mockImplementation((obj) => obj); + mockGetAppConfig.mockReturnValue({ LMS_BASE_URL: 'https://example.com' }); + mockGetAuthenticatedHttpClient.mockReturnValue(mockHttpClient as any); + }); + + it('should fetch pending tasks successfully', async () => { + const mockCourseId = 'course-v1:Example+Course+2025'; + const mockTasks = [ + { + task_type: 'grade_course', + task_id: '12345', + task_state: 'SUCCESS', + requester: 'instructor@example.com', + }, + ]; + + mockHttpClient.post.mockResolvedValue({ + data: { tasks: mockTasks }, + }); + + const result = await fetchPendingTasks(mockCourseId); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + 'https://example.com/courses/course-v1:Example+Course+2025/instructor/api/list_instructor_tasks' + ); + expect(result).toEqual(mockTasks); + }); }); }); diff --git a/src/data/api.ts b/src/data/api.ts index 1acb02c3..77fd55a4 100644 --- a/src/data/api.ts +++ b/src/data/api.ts @@ -13,3 +13,15 @@ export const getCourseInfo = async (courseId) => { .get(`${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}`); return camelCaseObject(data); }; + +/** + * Fetch pending instructor tasks for a course. + * @param {string} courseId + * @returns {Promise} + */ +export const fetchPendingTasks = async (courseId: string) => { + const response = await getAuthenticatedHttpClient().post<{ results: Record[] }>( + `${getApiBaseUrl()}/courses/${courseId}/instructor/api/list_instructor_tasks` + ); + return response.data?.tasks?.map(camelCaseObject); +}; diff --git a/src/data/apiHook.test.tsx b/src/data/apiHook.test.tsx index 465775c8..23e96e76 100644 --- a/src/data/apiHook.test.tsx +++ b/src/data/apiHook.test.tsx @@ -1,11 +1,12 @@ import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useCourseInfo } from './apiHook'; -import { getCourseInfo } from './api'; +import { useCourseInfo, usePendingTasks } from './apiHook'; +import { fetchPendingTasks, getCourseInfo } from './api'; jest.mock('./api'); const mockGetCourseInfo = getCourseInfo as jest.MockedFunction; +const mockFetchPendingTasks = fetchPendingTasks as jest.MockedFunction; const createWrapper = () => { const queryClient = new QueryClient({ @@ -20,45 +21,73 @@ const createWrapper = () => { Wrapper.displayName = 'TestWrapper'; return Wrapper; }; +describe('api hooks', () => { + describe('useCourseInfo', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); -describe('useCourseInfo', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); + it('fetches course info successfully', async () => { + const mockCourseData = { courseName: 'Test Course' }; + mockGetCourseInfo.mockResolvedValue(mockCourseData); - it('fetches course info successfully', async () => { - const mockCourseData = { courseName: 'Test Course' }; - mockGetCourseInfo.mockResolvedValue(mockCourseData); + const { result } = renderHook(() => useCourseInfo('test-course-123'), { + wrapper: createWrapper(), + }); - const { result } = renderHook(() => useCourseInfo('test-course-123'), { - wrapper: createWrapper(), - }); + expect(result.current.isLoading).toBe(true); - expect(result.current.isLoading).toBe(true); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); + expect(mockGetCourseInfo).toHaveBeenCalledWith('test-course-123'); + expect(result.current.data).toBe(mockCourseData); + expect(result.current.error).toBe(null); }); - expect(mockGetCourseInfo).toHaveBeenCalledWith('test-course-123'); - expect(result.current.data).toBe(mockCourseData); - expect(result.current.error).toBe(null); - }); + it('handles API error', async () => { + const mockError = new Error('API Error'); + mockGetCourseInfo.mockRejectedValue(mockError); - it('handles API error', async () => { - const mockError = new Error('API Error'); - mockGetCourseInfo.mockRejectedValue(mockError); + const { result } = renderHook(() => useCourseInfo('test-course-456'), { + wrapper: createWrapper(), + }); - const { result } = renderHook(() => useCourseInfo('test-course-456'), { - wrapper: createWrapper(), - }); + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); - await waitFor(() => { - expect(result.current.isError).toBe(true); + expect(mockGetCourseInfo).toHaveBeenCalledWith('test-course-456'); + expect(result.current.error).toBe(mockError); + expect(result.current.data).toBe(undefined); }); + }); + describe('usePendingTasks', () => { + it('should successfully fetch pending tasks when mutate is called', async () => { + const mockTasks = [ + { taskType: 'grade_course', taskId: '12345', taskState: 'SUCCESS' }, + ]; + const mockCourseId = 'course-v1:Example+Course+2025'; - expect(mockGetCourseInfo).toHaveBeenCalledWith('test-course-456'); - expect(result.current.error).toBe(mockError); - expect(result.current.data).toBe(undefined); + mockFetchPendingTasks.mockResolvedValue(mockTasks); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => usePendingTasks(mockCourseId), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockFetchPendingTasks).toHaveBeenCalledWith('course-v1:Example+Course+2025'); + expect(result.current.data).toEqual(mockTasks); + }); }); }); diff --git a/src/data/apiHook.ts b/src/data/apiHook.ts index b5efb3b2..4372193e 100644 --- a/src/data/apiHook.ts +++ b/src/data/apiHook.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; -import { getCourseInfo } from './api'; -import { courseInfoQueryKeys } from './queryKeys'; +import { fetchPendingTasks, getCourseInfo } from './api'; +import { courseInfoQueryKeys, pendingTasksQueryKey } from './queryKeys'; export const useCourseInfo = (courseId: string) => ( useQuery({ @@ -8,3 +8,12 @@ export const useCourseInfo = (courseId: string) => ( queryFn: () => getCourseInfo(courseId), }) ); + +export const usePendingTasks = (courseId: string, options?: { enablePolling?: boolean }) => { + return useQuery({ + queryKey: pendingTasksQueryKey.byCourse(courseId), + queryFn: async () => fetchPendingTasks(courseId), + enabled: !!courseId, + refetchInterval: options?.enablePolling ? 3000 : false, + }); +}; diff --git a/src/data/queryKeys.ts b/src/data/queryKeys.ts index eb1d0518..583d978a 100644 --- a/src/data/queryKeys.ts +++ b/src/data/queryKeys.ts @@ -4,3 +4,8 @@ export const courseInfoQueryKeys = { all: [appId, 'courseInfo'] as const, byCourse: (courseId: string) => [appId, 'courseInfo', courseId] as const, }; + +export const pendingTasksQueryKey = { + all: [appId, 'pendingTasks'] as const, + byCourse: (courseId: string) => [appId, 'pendingTasks', courseId] as const, +}; diff --git a/src/dataDownloads/DataDownloadsPage.test.tsx b/src/dataDownloads/DataDownloadsPage.test.tsx index 5a0de5e6..5dd31685 100644 --- a/src/dataDownloads/DataDownloadsPage.test.tsx +++ b/src/dataDownloads/DataDownloadsPage.test.tsx @@ -5,8 +5,9 @@ import { MemoryRouter } from 'react-router-dom'; import DataDownloadsPage from './DataDownloadsPage'; import { useGeneratedReports, useGenerateReportLink } from './data/apiHook'; import { AlertProvider } from '@src/providers/AlertProvider'; -import { renderWithIntl } from '@src/testUtils'; +import { renderWithQueryClient } from '@src/testUtils'; import messages from './messages'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; jest.mock('./data/apiHook'); jest.mock('@src/components/PageNotFound', () => ({ @@ -35,7 +36,7 @@ const mockReportsData = [ ]; const renderWithProviders = (component: React.ReactElement, courseId = 'course-123') => { - return renderWithIntl( + return renderWithQueryClient( {component} @@ -437,13 +438,15 @@ describe('DataDownloadsPage', () => { } as any); rerender( - - - - - - - + + + + + + + + + ); jest.useRealTimers(); @@ -672,4 +675,10 @@ describe('DataDownloadsPage', () => { consoleError.mockRestore(); }); + + it('should render pending tasks', async () => { + renderWithProviders(); + const pendingTasks = screen.getByText('Pending Tasks'); + expect(pendingTasks).toBeInTheDocument(); + }); }); diff --git a/src/dataDownloads/DataDownloadsPage.tsx b/src/dataDownloads/DataDownloadsPage.tsx index 9399ca15..52da4b2a 100644 --- a/src/dataDownloads/DataDownloadsPage.tsx +++ b/src/dataDownloads/DataDownloadsPage.tsx @@ -10,6 +10,7 @@ import { getApiBaseUrl } from '@src/data/api'; import { getReportTypeDisplayName } from './utils'; import PageNotFound from '@src/components/PageNotFound'; import { useAlert } from '@src/providers/AlertProvider'; +import { PendingTasks } from '@src/components/PendingTasks'; const DataDownloadsPage = () => { const intl = useIntl(); @@ -185,6 +186,8 @@ const DataDownloadsPage = () => { isGenerating={isGenerating} problemResponsesError={problemResponsesError} /> + + ); }; diff --git a/src/types/index.ts b/src/types/index.ts index 3064aa1f..4f40f374 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -24,3 +24,16 @@ export interface DataList { numPages: number, results: T[], }; + +export interface PendingTask { + taskType: string, + taskInput: Record, + taskId: string, + requester: string, + taskState: string, + created: string, + taskOutput: Record | null, + durationSec: number, + status: string, + taskMessage: string, +} diff --git a/src/utils/formatters.test.ts b/src/utils/formatters.test.ts new file mode 100644 index 00000000..c044a7c1 --- /dev/null +++ b/src/utils/formatters.test.ts @@ -0,0 +1,27 @@ +import { parseObject } from './formatters'; + +describe('parseObject', () => { + it('should parse and format valid JSON string', () => { + const jsonString = { course_id: 'course-v1:Example+Course+2023', report_type: 'enrolled_students' }; + const result = parseObject(jsonString); + + expect(result).toBe(`{ + "course_id": "course-v1:Example+Course+2023", + "report_type": "enrolled_students" +}`); + }); + + it('should return original string when JSON parsing fails', () => { + const invalidJson = 'invalid json string'; + const result = parseObject(invalidJson); + + expect(result).toBe('invalid json string'); + }); + + it('should handle null, undefined, and empty values', () => { + expect(parseObject(null)).toBe('null'); + expect(parseObject(undefined)).toBe(undefined); + expect(parseObject('')).toBe(''); + expect(parseObject({})).toBe('{}'); + }); +}); diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts new file mode 100644 index 00000000..31f933e1 --- /dev/null +++ b/src/utils/formatters.ts @@ -0,0 +1,10 @@ +export const parseObject = (input: any): string => { + if (typeof input === 'string') { + return input; + } + try { + return JSON.stringify(input, null, 2); + } catch { + return String(input); + } +};