From 0aa1bce9fbf00d00aef66e091d7903a11cc217d4 Mon Sep 17 00:00:00 2001 From: jacobo-dominguez-wgu Date: Wed, 10 Dec 2025 13:00:06 -0600 Subject: [PATCH] feat: adding pending task section on course info page --- src/components/ObjectCell.tsx | 15 ++++ src/components/PedingTasks.test.tsx | 101 +++++++++++++++++++++++ src/components/PendingTasks.tsx | 82 +++++++++++++++++++ src/components/messages.ts | 66 +++++++++++++++ src/courseInfo/CourseInfoPage.test.tsx | 5 ++ src/courseInfo/CourseInfoPage.tsx | 2 + src/data/api.test.ts | 100 ++++++++++++++++------- src/data/api.ts | 15 +++- src/data/apiHook.test.tsx | 108 +++++++++++++++---------- src/data/apiHook.ts | 13 ++- src/data/queryKeys.ts | 5 ++ src/testUtils.tsx | 32 ++++++++ src/types.ts | 18 +++++ src/utils/formatters.test.ts | 27 +++++++ src/utils/formatters.ts | 12 +++ 15 files changed, 525 insertions(+), 76 deletions(-) create mode 100644 src/components/ObjectCell.tsx create mode 100644 src/components/PedingTasks.test.tsx create mode 100644 src/components/PendingTasks.tsx create mode 100644 src/components/messages.ts create mode 100644 src/types.ts create mode 100644 src/utils/formatters.test.ts create mode 100644 src/utils/formatters.ts diff --git a/src/components/ObjectCell.tsx b/src/components/ObjectCell.tsx new file mode 100644 index 00000000..b396aef3 --- /dev/null +++ b/src/components/ObjectCell.tsx @@ -0,0 +1,15 @@ +import { parseObject } from '../utils/formatters'; + +interface ObjectCellProps { + value: Record | null, +} + +const ObjectCell = ({ value }: ObjectCellProps) => { + return ( +
+      {parseObject(value ?? '')}
+    
+ ); +}; + +export { ObjectCell }; diff --git a/src/components/PedingTasks.test.tsx b/src/components/PedingTasks.test.tsx new file mode 100644 index 00000000..71dbc21b --- /dev/null +++ b/src/components/PedingTasks.test.tsx @@ -0,0 +1,101 @@ +import { screen, waitFor } from '@testing-library/react'; +import { PendingTasks } from './PendingTasks'; +import { usePendingTasks } from '../data/apiHook'; +import { renderWithProviders } from '../testUtils'; + +jest.mock('../data/apiHook'); + +const mockUsePendingTasks = usePendingTasks as jest.MockedFunction; + +describe('PendingTasks', () => { + const mockFetchTasks = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUsePendingTasks.mockReturnValue({ + mutate: mockFetchTasks, + data: undefined, + isPending: false, + } as any); + }); + + it('should render the collapsible pending tasks section', () => { + renderWithProviders(); + + expect(screen.getByText('Pending Tasks')).toBeInTheDocument(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('should show loading skeleton when tasks are being fetched', async () => { + mockUsePendingTasks.mockReturnValue({ + mutate: mockFetchTasks, + data: undefined, + isPending: true, + } as any); + + const { container } = renderWithProviders(); + const toggleButton = screen.getByRole('button'); + await waitFor(() => 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); + + renderWithProviders(); + const toggleButton = screen.getByRole('button'); + await waitFor(() => 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({ + mutate: mockFetchTasks, + data: mockTasks, + isPending: false, + } as any); + + renderWithProviders(); + const toggleButton = screen.getByRole('button'); + await waitFor(() => 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 () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockFetchTasks).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/components/PendingTasks.tsx b/src/components/PendingTasks.tsx new file mode 100644 index 00000000..5edd15cc --- /dev/null +++ b/src/components/PendingTasks.tsx @@ -0,0 +1,82 @@ +import { useIntl } from '@openedx/frontend-base'; +import { Collapsible, DataTable, Icon, Skeleton } from '@openedx/paragon'; +import { useEffect, useMemo } from 'react'; +import { messages } from './messages'; +import { ExpandLess, ExpandMore } from '@openedx/paragon/icons'; +import { usePendingTasks } from '../data/apiHook'; +import { useParams } from 'react-router'; +import { ObjectCell } from './ObjectCell'; +import { PendingTask, TableCellValue } from '../types'; + +const PendingTasks = () => { + const intl = useIntl(); + const { courseId = '' } = useParams(); + const { mutate: fetchTasks, data: tasks, isPending } = usePendingTasks(courseId); + + 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]); + + useEffect(() => { + fetchTasks(); + }, [fetchTasks]); + + const renderContent = () => { + if (isPending) { + return ; + } + + if (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 new file mode 100644 index 00000000..f5f68f41 --- /dev/null +++ b/src/components/messages.ts @@ -0,0 +1,66 @@ +import { defineMessages } from '@openedx/frontend-base'; + +const messages = defineMessages({ + 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 { messages }; diff --git a/src/courseInfo/CourseInfoPage.test.tsx b/src/courseInfo/CourseInfoPage.test.tsx index 65329d22..6b845703 100644 --- a/src/courseInfo/CourseInfoPage.test.tsx +++ b/src/courseInfo/CourseInfoPage.test.tsx @@ -30,4 +30,9 @@ describe('CourseInfoPage', () => { renderComponent(); expect(screen.getByText('General Course Info Component')).toBeInTheDocument(); }); + + it('renders pending tasks section', () => { + renderComponent(); + expect(screen.getByText('Pending Tasks')).toBeInTheDocument(); + }); }); diff --git a/src/courseInfo/CourseInfoPage.tsx b/src/courseInfo/CourseInfoPage.tsx index 20137236..780abb0e 100644 --- a/src/courseInfo/CourseInfoPage.tsx +++ b/src/courseInfo/CourseInfoPage.tsx @@ -1,10 +1,12 @@ import { Container } from '@openedx/paragon'; import { GeneralCourseInfo } from './components/generalCourseInfo'; +import { PendingTasks } from '../components/PendingTasks'; const CourseInfoPage = () => { return ( + ); }; 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 145db04a..04f5f757 100644 --- a/src/data/api.ts +++ b/src/data/api.ts @@ -1,7 +1,7 @@ import { camelCaseObject, getAppConfig, getAuthenticatedHttpClient } from '@openedx/frontend-base'; import { appId } from '../constants'; -export const getApiBaseUrl = () => getAppConfig(appId).LMS_BASE_URL; +export const getApiBaseUrl = () => getAppConfig(appId).LMS_BASE_URL as string; /** * Get course settings. @@ -13,3 +13,16 @@ 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 httpClient = getAuthenticatedHttpClient(appId); + const response = await httpClient.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..a2a35799 100644 --- a/src/data/apiHook.test.tsx +++ b/src/data/apiHook.test.tsx @@ -1,64 +1,84 @@ 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 { getCourseInfo, fetchPendingTasks } from './api'; +import { createWrapper } from '../testUtils'; jest.mock('./api'); const mockGetCourseInfo = getCourseInfo as jest.MockedFunction; +const mockFetchPendingTasks = fetchPendingTasks as jest.MockedFunction; -const createWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - const Wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - Wrapper.displayName = 'TestWrapper'; - return Wrapper; -}; - -describe('useCourseInfo', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); +describe('api hooks', () => { + 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); }); + }); - expect(mockGetCourseInfo).toHaveBeenCalledWith('test-course-456'); - expect(result.current.error).toBe(mockError); - expect(result.current.data).toBe(undefined); + describe('base api hooks', () => { + 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'; + + 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 }); + + result.current.mutate(); + + 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..f9d95e80 100644 --- a/src/data/apiHook.ts +++ b/src/data/apiHook.ts @@ -1,6 +1,13 @@ -import { useQuery } from '@tanstack/react-query'; -import { getCourseInfo } from './api'; -import { courseInfoQueryKeys } from './queryKeys'; +import { courseInfoQueryKeys, pendingTasksQueryKey } from './queryKeys'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { getCourseInfo, fetchPendingTasks } from './api'; + +export const usePendingTasks = (courseId: string) => { + return useMutation({ + mutationKey: pendingTasksQueryKey.byCourse(courseId), + mutationFn: async () => fetchPendingTasks(courseId), + }); +}; export const useCourseInfo = (courseId: string) => ( useQuery({ diff --git a/src/data/queryKeys.ts b/src/data/queryKeys.ts index 83fb4cc3..39fe4d21 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/testUtils.tsx b/src/testUtils.tsx index 4f4aaf41..1a6e38f6 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -1,5 +1,7 @@ import { render } from '@testing-library/react'; import { IntlProvider } from '@openedx/frontend-base'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router'; export const renderWithIntl = (component) => { return render({ component }); @@ -15,3 +17,33 @@ export const createQueryMock = (data: any = undefined, isLoading = false) => ({ fetchStatus: isLoading ? 'fetching' : 'idle', refetch: jest.fn(), } as any); + +export const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + Wrapper.displayName = 'TestWrapper'; + return Wrapper; +}; + +export const renderWithProviders = (component: React.ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + return render( + + + + {component} + + + + ); +}; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..7cb42f64 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,18 @@ +export interface TableCellValue { + row: { + original: 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..58da4379 --- /dev/null +++ b/src/utils/formatters.ts @@ -0,0 +1,12 @@ +const parseObject = (input: any): string => { + if (typeof input === 'string') { + return input; + } + try { + return JSON.stringify(input, null, 2); + } catch { + return String(input); + } +}; + +export { parseObject };