Skip to content

Commit e92bf0a

Browse files
feat: add actions to attempts and fix missing attributes on table
1 parent 6881b77 commit e92bf0a

12 files changed

Lines changed: 202 additions & 28 deletions

src/specialExams/SpecialExamsPage.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ jest.mock('./components/Allowances', () => {
1010
}
1111
return MockedAllowances;
1212
});
13-
jest.mock('./components/AttemptsList', () => {
13+
jest.mock('./components/Attempts', () => {
1414
function MockedAttemptsList() {
15-
return <div>AttemptsList Component</div>;
15+
return <div>Attempts Component</div>;
1616
}
1717
return MockedAttemptsList;
1818
});
@@ -26,7 +26,7 @@ describe('SpecialExamsPage', () => {
2626
it('renders the attempts tab and its content by default', () => {
2727
renderWithIntl(<SpecialExamsPage />);
2828
expect(screen.getByText('Exam Attempts')).toBeInTheDocument();
29-
expect(screen.getByText('AttemptsList Component')).toBeInTheDocument();
29+
expect(screen.getByText('Attempts Component')).toBeInTheDocument();
3030
expect(screen.queryByText('Allowances Component')).not.toBeInTheDocument();
3131
});
3232

@@ -43,7 +43,7 @@ describe('SpecialExamsPage', () => {
4343
const user = userEvent.setup();
4444
await user.click(screen.getByText('Allowances'));
4545
await user.click(screen.getByText('Exam Attempts'));
46-
expect(screen.getByText('AttemptsList Component')).toBeInTheDocument();
46+
expect(screen.getByText('Attempts Component')).toBeInTheDocument();
4747
expect(screen.queryByText('Allowances Component')).not.toBeInTheDocument();
4848
});
4949

src/specialExams/SpecialExamsPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useIntl } from '@openedx/frontend-base';
33
import { Button, ButtonGroup, Card } from '@openedx/paragon';
44
import messages from './messages';
55
import Allowances from './components/Allowances';
6-
import AttemptsList from './components/AttemptsList';
6+
import Attempts from './components/Attempts';
77

88
const SPECIAL_EXAMS_TAB = {
99
ATTEMPTS: 'attempts',
@@ -31,7 +31,7 @@ const SpecialExamsPage = () => {
3131
</Button>
3232
</ButtonGroup>
3333
{
34-
selectedTab === SPECIAL_EXAMS_TAB.ATTEMPTS ? <AttemptsList /> : <Allowances />
34+
selectedTab === SPECIAL_EXAMS_TAB.ATTEMPTS ? <Attempts /> : <Allowances />
3535
}
3636
</Card>
3737
</>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useParams } from 'react-router-dom';
2+
import { Attempt } from '../types';
3+
import AttemptsList from './AttemptsList';
4+
import { useResetAttempt } from '../data/apiHook';
5+
6+
const Attempts = () => {
7+
const { courseId = '' } = useParams<{ courseId: string }>();
8+
const { mutate: resetAttempt } = useResetAttempt(courseId);
9+
10+
const handleResume = (attempt: Attempt) => {
11+
// Implement resume logic here
12+
console.log('Resume attempt:', courseId, attempt);
13+
};
14+
15+
const handleReset = (attempt: Attempt) => {
16+
const { user, examId } = attempt;
17+
resetAttempt({ username: user.username, examId })
18+
};
19+
20+
return (
21+
<AttemptsList onResume={handleResume} onReset={handleReset} />
22+
);
23+
};
24+
25+
export default Attempts;

src/specialExams/components/AttemptsList.test.tsx

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const mockExamAttempts = {
2121
},
2222
examName: 'Midterm',
2323
allowedTimeLimitMins: 60,
24-
type: 'proctored',
24+
examType: 'proctored',
2525
startTime: '2024-01-01',
2626
endTime: '2024-01-02',
2727
status: 'completed',
@@ -35,14 +35,18 @@ describe('AttemptsList', () => {
3535
beforeEach(() => {
3636
jest.clearAllMocks();
3737
});
38+
const mockReset = jest.fn();
39+
const mockResume = jest.fn();
40+
41+
const renderComponent = () => renderWithIntl(<AttemptsList onReset={mockReset} onResume={mockResume} />);
3842

3943
it('renders DataTable with correct columns and empty data', () => {
4044
(useAttempts as jest.Mock).mockReturnValue({
4145
data: { results: [], count: 0, numPages: 0 },
4246
isLoading: false,
4347
});
4448

45-
renderWithIntl(<AttemptsList />);
49+
renderComponent();
4650

4751
expect(screen.getByText('No exam attempts found')).toBeInTheDocument();
4852
expect(screen.getByPlaceholderText('Search By Username or Email')).toBeInTheDocument();
@@ -54,7 +58,7 @@ describe('AttemptsList', () => {
5458
isLoading: true,
5559
});
5660

57-
renderWithIntl(<AttemptsList />);
61+
renderComponent();
5862
expect(screen.getByRole('status')).toBeInTheDocument();
5963
});
6064

@@ -64,13 +68,13 @@ describe('AttemptsList', () => {
6468
isLoading: false,
6569
});
6670

67-
renderWithIntl(<AttemptsList />);
71+
renderComponent();
6872
expect(screen.getByText(mockExamAttempts.results[0].user.username)).toBeInTheDocument();
6973
expect(screen.getByText(mockExamAttempts.results[0].examName)).toBeInTheDocument();
7074
expect(screen.getByText(mockExamAttempts.results[0].allowedTimeLimitMins.toString())).toBeInTheDocument();
71-
expect(screen.getByText(mockExamAttempts.results[0].type)).toBeInTheDocument();
72-
expect(screen.getByText(mockExamAttempts.results[0].startTime)).toBeInTheDocument();
73-
expect(screen.getByText(mockExamAttempts.results[0].endTime)).toBeInTheDocument();
75+
expect(screen.getByText(mockExamAttempts.results[0].examType)).toBeInTheDocument();
76+
expect(screen.getByText(/01\/01\/2024.*UTC/)).toBeInTheDocument();
77+
expect(screen.getByText(/01\/02\/2024.*UTC/)).toBeInTheDocument();
7478
expect(screen.getByText(mockExamAttempts.results[0].status)).toBeInTheDocument();
7579
});
7680

@@ -80,7 +84,7 @@ describe('AttemptsList', () => {
8084
isLoading: false,
8185
});
8286

83-
renderWithIntl(<AttemptsList />);
87+
renderComponent();
8488

8589
const input = screen.getByRole('textbox');
8690
const user = userEvent.setup();
@@ -99,7 +103,7 @@ describe('AttemptsList', () => {
99103
isLoading: false,
100104
});
101105

102-
renderWithIntl(<AttemptsList />);
106+
renderComponent();
103107
expect(useAttempts).toHaveBeenCalledWith('course-v1:edX+Test+2024', expect.any(Object));
104108
});
105109
});

src/specialExams/components/AttemptsList.tsx

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
import { useMemo, useState } from 'react';
22
import { useParams } from 'react-router-dom';
33
import { useIntl } from '@openedx/frontend-base';
4-
import { DataTable } from '@openedx/paragon';
4+
import { DataTable, IconButton, OverlayTrigger, Popover } from '@openedx/paragon';
5+
import { MoreVert } from '@openedx/paragon/icons';
56
import UsernameFilter from '@src/components/UsernameFilter';
6-
import messages from '@src/specialExams/messages';
77
import { useAttempts } from '@src/specialExams/data/apiHook';
8-
import { DataTableFetchDataProps } from '@src/types';
8+
import messages from '@src/specialExams/messages';
9+
import { Attempt } from '@src/specialExams/types';
10+
import { DataTableFetchDataProps, TableCellValue } from '@src/types';
911

1012
export const ATTEMPTS_PAGE_SIZE = 25;
1113

12-
const AttemptsList = () => {
14+
interface AttemptsListProps {
15+
onResume: (attempt: Attempt) => void,
16+
onReset: (attempt: Attempt) => void,
17+
}
18+
19+
const AttemptsList = ({ onResume, onReset }: AttemptsListProps) => {
1320
const intl = useIntl();
1421
const { courseId = '' } = useParams();
1522
const [filters, setFilters] = useState({ page: 0, emailOrUsername: '', ordering: '' });
@@ -18,16 +25,107 @@ const AttemptsList = () => {
1825
pageSize: ATTEMPTS_PAGE_SIZE
1926
});
2027

28+
const ActionCustomCell = ({ row: { original } }: TableCellValue<Attempt>) => {
29+
const popoverContent = (
30+
<Popover
31+
id={`popover-${original.user.username}-${original.examName}`}
32+
className="border-0 shadow-sm"
33+
>
34+
<Popover.Content className="p-0 border-0">
35+
<div className="dropdown-menu show position-static border shadow-sm">
36+
<button
37+
type="button"
38+
className="dropdown-item"
39+
onClick={() => original.readyToResume ? onResume(original) : onReset(original)}
40+
>
41+
{original.readyToResume ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.reset)}
42+
</button>
43+
</div>
44+
</Popover.Content>
45+
</Popover>
46+
);
47+
return (
48+
<>
49+
<OverlayTrigger
50+
trigger="click"
51+
placement="bottom-end"
52+
overlay={popoverContent}
53+
rootClose
54+
>
55+
<IconButton
56+
alt={intl.formatMessage(messages.actions)}
57+
className="lead"
58+
iconAs={MoreVert}
59+
/>
60+
</OverlayTrigger>
61+
</>
62+
);
63+
};
64+
2165
const columns = useMemo(() => [
2266
{ accessor: 'user.username', Header: intl.formatMessage(messages.username), Filter: UsernameFilter, },
2367
{ accessor: 'examName', Header: intl.formatMessage(messages.examName), disableFilters: true, },
2468
{ accessor: 'allowedTimeLimitMins', Header: intl.formatMessage(messages.timeLimit), disableFilters: true, },
25-
{ accessor: 'type', Header: intl.formatMessage(messages.type), disableFilters: true, },
26-
{ accessor: 'startTime', Header: intl.formatMessage(messages.startedAt), disableFilters: true, },
27-
{ accessor: 'endTime', Header: intl.formatMessage(messages.completedAt), disableFilters: true, },
28-
{ accessor: 'status', Header: intl.formatMessage(messages.status), disableFilters: true, },
69+
{
70+
accessor: 'examType',
71+
Cell: ({ row }: TableCellValue<any>) => <span className="text-capitalize">{row.original.examType}</span>,
72+
disableFilters: true,
73+
Header: intl.formatMessage(messages.type),
74+
},
75+
{
76+
accessor: 'startTime',
77+
Cell: ({ row }: TableCellValue<any>) => (
78+
<span>{ row.original.startTime ? `${intl.formatDate(new Date(row.original.startTime), {
79+
year: 'numeric',
80+
month: '2-digit',
81+
day: '2-digit',
82+
hour: '2-digit',
83+
minute: '2-digit',
84+
timeZone: 'UTC',
85+
})} UTC` : ''}
86+
</span>
87+
),
88+
disableFilters: true,
89+
Header: intl.formatMessage(messages.startedAt),
90+
},
91+
{
92+
accessor: 'endTime',
93+
Cell: ({ row }: TableCellValue<any>) => (
94+
<span>{row.original.endTime ? `${intl.formatDate(new Date(row.original.endTime), {
95+
year: 'numeric',
96+
month: '2-digit',
97+
day: '2-digit',
98+
hour: '2-digit',
99+
minute: '2-digit',
100+
timeZone: 'UTC',
101+
})} UTC` : ''}
102+
</span>
103+
),
104+
disableFilters: true,
105+
Header: intl.formatMessage(messages.completedAt),
106+
},
107+
{
108+
accessor: 'status',
109+
Cell: ({ row }: TableCellValue<any>) => <span className="text-capitalize">{row.original.status}</span>,
110+
disableFilters: true,
111+
Header: intl.formatMessage(messages.status),
112+
},
113+
{
114+
accessor: 'readyToResume',
115+
Cell: ({ row }: TableCellValue<any>) => (
116+
<span className="text-capitalize">{row.original.readyToResume ? intl.formatMessage(messages.true) : ''}</span>
117+
),
118+
disableFilters: true,
119+
Header: intl.formatMessage(messages.readyForResume),
120+
}
29121
], [intl]);
30122

123+
const additionalColumns = [{
124+
id: 'actions',
125+
Header: '',
126+
Cell: ActionCustomCell,
127+
}];
128+
31129
const handleFetchData = (data: DataTableFetchDataProps) => {
32130
const emailOrUsernameFilter = data.filters?.find((f) => f.id === 'user.username');
33131
const newEmailOrUsername = emailOrUsernameFilter ? emailOrUsernameFilter.value : '';
@@ -44,6 +142,7 @@ const AttemptsList = () => {
44142

45143
return (
46144
<DataTable
145+
additionalColumns={additionalColumns}
47146
className="mt-3"
48147
columns={columns}
49148
data={data.results}

src/specialExams/data/api.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ describe('specialExams api', () => {
8686
startTime: '2023-01-01T10:00:00Z',
8787
endTime: '2023-01-01T13:00:00Z',
8888
status: 'completed',
89+
readyToResume: false
8990
},
9091
{
9192
id: 2,
@@ -99,6 +100,7 @@ describe('specialExams api', () => {
99100
startTime: '2023-01-02T14:00:00Z',
100101
endTime: '2023-01-02T16:00:00Z',
101102
status: 'completed',
103+
readyToResume: true
102104
},
103105
],
104106
};

src/specialExams/data/api.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { getAuthenticatedHttpClient, camelCaseObject, snakeCaseObject } from '@o
22
import { snakeCase } from 'lodash';
33
import { getApiBaseUrl } from '@src/data/api';
44
import { DataList } from '@src/types';
5-
import { AddAllowanceParams, Allowance, Attempt, AttemptsParams, DeleteAllowanceParams, SpecialExam } from '../types';
5+
import { AddAllowanceParams, Allowance, Attempt, AttemptsParams, DeleteAllowanceParams, ResetAttemptParams, SpecialExam } from '../types';
66

77
const getQueryParams = (params: AttemptsParams) => {
88
const queryParams = new URLSearchParams({
@@ -69,3 +69,9 @@ export const getSpecialExams = async (courseId: string, examType: string): Promi
6969
);
7070
return camelCaseObject(data);
7171
};
72+
73+
export const resetAttempt = async (courseId: string, params: ResetAttemptParams ) => {
74+
await getAuthenticatedHttpClient().post(
75+
`${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/special_exams/${params.examId}/reset/${params.username}`
76+
);
77+
}

src/specialExams/data/apiHook.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const mockAttemptsData = {
2828
startTime: '2023-01-01T10:00:00Z',
2929
endTime: '2023-01-01T13:00:00Z',
3030
status: 'completed',
31+
readyToResume: false
3132
},
3233
{
3334
id: 2,
@@ -41,6 +42,7 @@ const mockAttemptsData = {
4142
startTime: '2023-01-02T14:00:00Z',
4243
endTime: '2023-01-02T16:00:00Z',
4344
status: 'completed',
45+
readyToResume: true
4446
},
4547
],
4648
};

src/specialExams/data/apiHook.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2-
import { addAllowance, deleteAllowance, getAllowances, getAttempts, getSpecialExams } from './api';
3-
import { specialExamsQueryKeys } from './queryKeys';
4-
import { AddAllowanceParams, AttemptsParams, DeleteAllowanceParams } from '../types';
2+
import { addAllowance, deleteAllowance, getAllowances, getAttempts, getSpecialExams, resetAttempt } from '@src/specialExams/data/api';
3+
import { specialExamsQueryKeys } from '@src/specialExams/data/queryKeys';
4+
import { AddAllowanceParams, AttemptsParams, DeleteAllowanceParams, ResetAttemptParams } from '@src/specialExams/types';
55

66
export const useAttempts = (courseId: string, params: AttemptsParams, enabled = true) => (
77
useQuery({
@@ -47,3 +47,13 @@ export const useSpecialExams = (courseId: string, examType: string) => (
4747
enabled: !!courseId && !!examType,
4848
})
4949
);
50+
51+
export const useResetAttempt = (courseId: string) => {
52+
const queryClient = useQueryClient();
53+
return useMutation({
54+
mutationFn: (params: ResetAttemptParams) => resetAttempt(courseId, params),
55+
onSuccess: () => {
56+
queryClient.invalidateQueries({ queryKey: specialExamsQueryKeys.attempts(courseId), exact: false });
57+
},
58+
})
59+
};

src/specialExams/data/queryKeys.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { AttemptsParams } from '../types';
44
export const specialExamsQueryKeys = {
55
all: [appId, 'specialExams'] as const,
66
byCourse: (courseId: string) => [...specialExamsQueryKeys.all, courseId] as const,
7-
attempts: (courseId: string, params: AttemptsParams) => [...specialExamsQueryKeys.byCourse(courseId), 'attempts', params.page, params.emailOrUsername, params.ordering] as const,
7+
attempts: (courseId: string, params?: AttemptsParams) => [...specialExamsQueryKeys.byCourse(courseId), 'attempts', params?.page || 1, params?.emailOrUsername || '', params?.ordering || ''] as const,
88
allowances: (courseId: string, params?: AttemptsParams) => [...specialExamsQueryKeys.byCourse(courseId), 'allowances', params?.page || 1, params?.emailOrUsername || '', params?.ordering || ''] as const,
99
specialExams: (courseId: string, examType: string) => [...specialExamsQueryKeys.byCourse(courseId), 'specialExams', examType] as const,
1010
};

0 commit comments

Comments
 (0)