Skip to content

Commit 224e3db

Browse files
feat: adding pending tasks section
1 parent c08d7f8 commit 224e3db

File tree

14 files changed

+525
-88
lines changed

14 files changed

+525
-88
lines changed

src/components/ObjectCell.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { parseObject } from '../utils/formatters';
2+
3+
interface ObjectCellProps {
4+
value: Record<string, any> | null,
5+
}
6+
7+
const ObjectCell = ({ value }: ObjectCellProps) => {
8+
return (
9+
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
10+
{parseObject(value ?? '')}
11+
</pre>
12+
);
13+
};
14+
15+
export { ObjectCell };
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { screen, waitFor } from '@testing-library/react';
2+
import { PendingTasks } from './PendingTasks';
3+
import { usePendingTasks } from '../data/apiHook';
4+
import { renderWithProviders } from '../testUtils';
5+
6+
jest.mock('../data/apiHook');
7+
8+
const mockUsePendingTasks = usePendingTasks as jest.MockedFunction<typeof usePendingTasks>;
9+
10+
describe('PendingTasks', () => {
11+
const mockFetchTasks = jest.fn();
12+
13+
beforeEach(() => {
14+
jest.clearAllMocks();
15+
mockUsePendingTasks.mockReturnValue({
16+
mutate: mockFetchTasks,
17+
data: undefined,
18+
isPending: false,
19+
} as any);
20+
});
21+
22+
it('should render the collapsible pending tasks section', () => {
23+
renderWithProviders(<PendingTasks />);
24+
25+
expect(screen.getByText('Pending Tasks')).toBeInTheDocument();
26+
expect(screen.getByRole('button')).toBeInTheDocument();
27+
});
28+
29+
it('should show loading skeleton when tasks are being fetched', async () => {
30+
mockUsePendingTasks.mockReturnValue({
31+
mutate: mockFetchTasks,
32+
data: undefined,
33+
isPending: true,
34+
} as any);
35+
36+
const { container } = renderWithProviders(<PendingTasks />);
37+
const toggleButton = screen.getByRole('button');
38+
await waitFor(() => toggleButton.click());
39+
40+
expect(screen.queryByText('No tasks currently running.')).not.toBeInTheDocument();
41+
expect(screen.queryByRole('table')).not.toBeInTheDocument();
42+
expect(screen.queryByText('Task Type')).not.toBeInTheDocument();
43+
44+
const skeletons = container.querySelectorAll('.react-loading-skeleton');
45+
expect(skeletons).toHaveLength(3);
46+
});
47+
48+
it('should display no tasks message when tasks array is empty', async () => {
49+
mockUsePendingTasks.mockReturnValue({
50+
mutate: mockFetchTasks,
51+
data: [],
52+
isPending: false,
53+
} as any);
54+
55+
renderWithProviders(<PendingTasks />);
56+
const toggleButton = screen.getByRole('button');
57+
await waitFor(() => toggleButton.click());
58+
59+
expect(screen.getByText('No tasks currently running.')).toBeInTheDocument();
60+
});
61+
62+
it('should render data table with tasks when data is available', async () => {
63+
const mockTasks = [
64+
{
65+
taskType: 'grade_course',
66+
taskInput: 'course data',
67+
taskId: '12345',
68+
requester: 'instructor@example.com',
69+
taskState: 'SUCCESS',
70+
created: '2023-01-01',
71+
taskOutput: 'output.csv',
72+
duration: '5 minutes',
73+
status: 'Completed',
74+
taskMessage: 'Task completed successfully',
75+
},
76+
];
77+
78+
mockUsePendingTasks.mockReturnValue({
79+
mutate: mockFetchTasks,
80+
data: mockTasks,
81+
isPending: false,
82+
} as any);
83+
84+
renderWithProviders(<PendingTasks />);
85+
const toggleButton = screen.getByRole('button');
86+
await waitFor(() => toggleButton.click());
87+
88+
expect(screen.getByText('Task Type')).toBeInTheDocument();
89+
expect(screen.getByText('Task ID')).toBeInTheDocument();
90+
expect(screen.getByText('grade_course')).toBeInTheDocument();
91+
expect(screen.getByText('12345')).toBeInTheDocument();
92+
});
93+
94+
it('should fetch tasks on component mount', async () => {
95+
renderWithProviders(<PendingTasks />);
96+
97+
await waitFor(() => {
98+
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
99+
});
100+
});
101+
});

src/components/PendingTasks.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { useIntl } from '@openedx/frontend-base';
2+
import { Collapsible, DataTable, Icon, Skeleton } from '@openedx/paragon';
3+
import { useEffect, useMemo } from 'react';
4+
import { messages } from './messages';
5+
import { ExpandLess, ExpandMore } from '@openedx/paragon/icons';
6+
import { usePendingTasks } from '../data/apiHook';
7+
import { useParams } from 'react-router';
8+
import { ObjectCell } from './ObjectCell';
9+
import { PendingTask, TableCellValue } from '../types';
10+
11+
const PendingTasks = () => {
12+
const intl = useIntl();
13+
const { courseId = '' } = useParams();
14+
const { mutate: fetchTasks, data: tasks, isPending } = usePendingTasks(courseId);
15+
16+
const tableColumns = useMemo(() => [
17+
{ accessor: 'taskType', Header: intl.formatMessage(messages.taskTypeColumnName) },
18+
{ accessor: 'taskInput', Header: intl.formatMessage(messages.taskInputColumnName), Cell: ({ row }: TableCellValue<PendingTask>) => <ObjectCell value={row.original.taskInput} /> },
19+
{ accessor: 'taskId', Header: intl.formatMessage(messages.taskIdColumnName) },
20+
{ accessor: 'requester', Header: intl.formatMessage(messages.requesterColumnName) },
21+
{ accessor: 'taskState', Header: intl.formatMessage(messages.taskStateColumnName) },
22+
{ accessor: 'created', Header: intl.formatMessage(messages.createdColumnName) },
23+
{ accessor: 'taskOutput', Header: intl.formatMessage(messages.taskOutputColumnName), Cell: ({ row }: TableCellValue<PendingTask>) => <ObjectCell value={row.original.taskOutput} /> },
24+
{ accessor: 'durationSec', Header: intl.formatMessage(messages.durationColumnName) },
25+
{ accessor: 'status', Header: intl.formatMessage(messages.statusColumnName) },
26+
{ accessor: 'taskMessage', Header: intl.formatMessage(messages.taskMessageColumnName) },
27+
], [intl]);
28+
29+
useEffect(() => {
30+
fetchTasks();
31+
}, [fetchTasks]);
32+
33+
const renderContent = () => {
34+
if (isPending) {
35+
return <Skeleton count={3} />;
36+
}
37+
38+
if (tasks?.length === 0) {
39+
return <div className="my-3">{intl.formatMessage(messages.noTasksMessage)}</div>;
40+
}
41+
42+
return (
43+
<DataTable
44+
columns={tableColumns}
45+
data={tasks}
46+
isLoading={isPending}
47+
RowStatusComponent={() => null}
48+
/>
49+
);
50+
};
51+
52+
return (
53+
<Collapsible.Advanced
54+
className="mt-4 pt-4 border-top"
55+
styling="basic"
56+
>
57+
<Collapsible.Trigger
58+
className="collapsible-trigger d-flex border-0 align-items-center text-decoration-none"
59+
>
60+
<div className="d-flex">
61+
<h3>{intl.formatMessage(messages.pendingTasksTitle)}</h3>
62+
</div>
63+
64+
<Collapsible.Visible whenClosed>
65+
<div className="pl-2 d-flex">
66+
<Icon src={ExpandMore} />
67+
</div>
68+
</Collapsible.Visible>
69+
<Collapsible.Visible whenOpen>
70+
<div className="pl-2 d-flex">
71+
<Icon src={ExpandLess} />
72+
</div>
73+
</Collapsible.Visible>
74+
</Collapsible.Trigger>
75+
<Collapsible.Body>
76+
{renderContent() }
77+
</Collapsible.Body>
78+
</Collapsible.Advanced>
79+
);
80+
};
81+
82+
export { PendingTasks };

src/components/messages.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { defineMessages } from '@openedx/frontend-base';
2+
3+
const messages = defineMessages({
4+
pendingTasksTitle: {
5+
id: 'instruct.pendingTasks.section.title',
6+
defaultMessage: 'Pending Tasks',
7+
description: 'Title for the pending tasks section',
8+
},
9+
noTasksMessage: {
10+
id: 'instruct.pendingTasks.section.noTasks',
11+
defaultMessage: 'No tasks currently running.',
12+
description: 'Message displayed when there are no pending tasks',
13+
},
14+
taskTypeColumnName: {
15+
id: 'instruct.pendingTasks.table.column.taskType',
16+
defaultMessage: 'Task Type',
17+
description: 'Column name for task type in pending tasks table',
18+
},
19+
taskInputColumnName: {
20+
id: 'instruct.pendingTasks.table.column.taskInput',
21+
defaultMessage: 'Task Input',
22+
description: 'Column name for task input in pending tasks table',
23+
},
24+
taskIdColumnName: {
25+
id: 'instruct.pendingTasks.table.column.taskId',
26+
defaultMessage: 'Task ID',
27+
description: 'Column name for task ID in pending tasks table',
28+
},
29+
requesterColumnName: {
30+
id: 'instruct.pendingTasks.table.column.requester',
31+
defaultMessage: 'Requester',
32+
description: 'Column name for requester in pending tasks table',
33+
},
34+
taskStateColumnName: {
35+
id: 'instruct.pendingTasks.table.column.taskState',
36+
defaultMessage: 'Task State',
37+
description: 'Column name for task state in pending tasks table',
38+
},
39+
createdColumnName: {
40+
id: 'instruct.pendingTasks.table.column.created',
41+
defaultMessage: 'Created',
42+
description: 'Column name for created date in pending tasks table',
43+
},
44+
taskOutputColumnName: {
45+
id: 'instruct.pendingTasks.table.column.taskOutput',
46+
defaultMessage: 'Task Output',
47+
description: 'Column name for task output in pending tasks table',
48+
},
49+
durationColumnName: {
50+
id: 'instruct.pendingTasks.table.column.duration',
51+
defaultMessage: 'Duration (sec)',
52+
description: 'Column name for duration in pending tasks table',
53+
},
54+
statusColumnName: {
55+
id: 'instruct.pendingTasks.table.column.status',
56+
defaultMessage: 'Status',
57+
description: 'Column name for status in pending tasks table',
58+
},
59+
taskMessageColumnName: {
60+
id: 'instruct.pendingTasks.table.column.taskMessage',
61+
defaultMessage: 'Task Message',
62+
description: 'Column name for task message in pending tasks table',
63+
},
64+
});
65+
66+
export { messages };

src/data/api.test.ts

Lines changed: 73 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,86 @@
11
import { getCourseInfo } from './api';
22
import { camelCaseObject, getAppConfig, getAuthenticatedHttpClient } from '@openedx/frontend-base';
3+
import { fetchPendingTasks } from './api';
34

4-
jest.mock('@openedx/frontend-base');
5+
jest.mock('@openedx/frontend-base', () => ({
6+
...jest.requireActual('@openedx/frontend-base'),
7+
camelCaseObject: jest.fn((obj) => obj),
8+
getAppConfig: jest.fn(),
9+
getAuthenticatedHttpClient: jest.fn(),
10+
}));
511

612
const mockGetAppConfig = getAppConfig as jest.MockedFunction<typeof getAppConfig>;
713
const mockGetAuthenticatedHttpClient = getAuthenticatedHttpClient as jest.MockedFunction<typeof getAuthenticatedHttpClient>;
814
const mockCamelCaseObject = camelCaseObject as jest.MockedFunction<typeof camelCaseObject>;
915

10-
describe('getCourseInfo', () => {
11-
const mockHttpClient = {
12-
get: jest.fn(),
13-
};
14-
const mockCourseData = { course_name: 'Test Course' };
15-
const mockCamelCaseData = { courseName: 'Test Course' };
16-
17-
beforeEach(() => {
18-
jest.clearAllMocks();
19-
mockGetAppConfig.mockReturnValue({ LMS_BASE_URL: 'https://test-lms.com' });
20-
mockGetAuthenticatedHttpClient.mockReturnValue(mockHttpClient as any);
21-
mockCamelCaseObject.mockReturnValue(mockCamelCaseData);
22-
mockHttpClient.get.mockResolvedValue({ data: mockCourseData });
16+
describe('base api', () => {
17+
afterEach(() => {
18+
jest.resetAllMocks();
2319
});
2420

25-
it('fetches course info successfully', async () => {
26-
const courseId = 'test-course-123';
27-
const result = await getCourseInfo(courseId);
28-
expect(mockGetAppConfig).toHaveBeenCalledWith('org.openedx.frontend.app.instructor');
29-
expect(mockGetAuthenticatedHttpClient).toHaveBeenCalled();
30-
expect(mockHttpClient.get).toHaveBeenCalledWith('https://test-lms.com/api/instructor/v2/courses/test-course-123');
31-
expect(mockCamelCaseObject).toHaveBeenCalledWith(mockCourseData);
32-
expect(result).toBe(mockCamelCaseData);
21+
describe('getCourseInfo', () => {
22+
const mockHttpClient = {
23+
get: jest.fn(),
24+
};
25+
const mockCourseData = { course_name: 'Test Course' };
26+
const mockCamelCaseData = { courseName: 'Test Course' };
27+
28+
beforeEach(() => {
29+
mockGetAppConfig.mockReturnValue({ LMS_BASE_URL: 'https://test-lms.com' });
30+
mockGetAuthenticatedHttpClient.mockReturnValue(mockHttpClient as any);
31+
mockCamelCaseObject.mockReturnValue(mockCamelCaseData);
32+
mockHttpClient.get.mockResolvedValue({ data: mockCourseData });
33+
});
34+
35+
it('fetches course info successfully', async () => {
36+
const courseId = 'test-course-123';
37+
const result = await getCourseInfo(courseId);
38+
expect(mockGetAppConfig).toHaveBeenCalledWith('org.openedx.frontend.app.instructor');
39+
expect(mockGetAuthenticatedHttpClient).toHaveBeenCalled();
40+
expect(mockHttpClient.get).toHaveBeenCalledWith('https://test-lms.com/api/instructor/v2/courses/test-course-123');
41+
expect(mockCamelCaseObject).toHaveBeenCalledWith(mockCourseData);
42+
expect(result).toBe(mockCamelCaseData);
43+
});
44+
45+
it('throws error when API call fails', async () => {
46+
const error = new Error('Network error');
47+
mockHttpClient.get.mockRejectedValue(error);
48+
await expect(getCourseInfo('test-course')).rejects.toThrow('Network error');
49+
});
3350
});
3451

35-
it('throws error when API call fails', async () => {
36-
const error = new Error('Network error');
37-
mockHttpClient.get.mockRejectedValue(error);
38-
await expect(getCourseInfo('test-course')).rejects.toThrow('Network error');
52+
describe('fetchPendingTasks', () => {
53+
const mockHttpClient = {
54+
post: jest.fn(),
55+
};
56+
57+
beforeEach(() => {
58+
mockCamelCaseObject.mockImplementation((obj) => obj);
59+
mockGetAppConfig.mockReturnValue({ LMS_BASE_URL: 'https://example.com' });
60+
mockGetAuthenticatedHttpClient.mockReturnValue(mockHttpClient as any);
61+
});
62+
63+
it('should fetch pending tasks successfully', async () => {
64+
const mockCourseId = 'course-v1:Example+Course+2025';
65+
const mockTasks = [
66+
{
67+
task_type: 'grade_course',
68+
task_id: '12345',
69+
task_state: 'SUCCESS',
70+
requester: 'instructor@example.com',
71+
},
72+
];
73+
74+
mockHttpClient.post.mockResolvedValue({
75+
data: { tasks: mockTasks },
76+
});
77+
78+
const result = await fetchPendingTasks(mockCourseId);
79+
80+
expect(mockHttpClient.post).toHaveBeenCalledWith(
81+
'https://example.com/courses/course-v1:Example+Course+2025/instructor/api/list_instructor_tasks'
82+
);
83+
expect(result).toEqual(mockTasks);
84+
});
3985
});
40-
});
86+
});

src/data/api.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,16 @@ export const getCourseInfo = async (courseId) => {
1313
.get(`${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}`);
1414
return camelCaseObject(data);
1515
};
16+
17+
/**
18+
* Fetch pending instructor tasks for a course.
19+
* @param {string} courseId
20+
* @returns {Promise<Array>}
21+
*/
22+
export const fetchPendingTasks = async (courseId: string) => {
23+
const httpClient = getAuthenticatedHttpClient(appId);
24+
const response = await httpClient.post<{ results: Record<string, any>[] }>(
25+
`${getApiBaseUrl()}/courses/${courseId}/instructor/api/list_instructor_tasks`
26+
);
27+
return response.data?.tasks?.map(camelCaseObject);
28+
};

0 commit comments

Comments
 (0)