Skip to content

Commit 4c5b7a9

Browse files
authored
feat: show course leaving stats for specific course (#2915)
* feat: search reports by course id * feat: provide courseId to api * refactor: use generated api * feat: add memoization to columns
1 parent 6a47194 commit 4c5b7a9

File tree

10 files changed

+474
-89
lines changed

10 files changed

+474
-89
lines changed

client/src/api/api.ts

Lines changed: 170 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3407,6 +3407,111 @@ export interface ExpelStatusDto {
34073407
*/
34083408
'expellingReason': string;
34093409
}
3410+
/**
3411+
*
3412+
* @export
3413+
* @interface ExpelledCourseDto
3414+
*/
3415+
export interface ExpelledCourseDto {
3416+
/**
3417+
*
3418+
* @type {number}
3419+
* @memberof ExpelledCourseDto
3420+
*/
3421+
'id': number;
3422+
/**
3423+
*
3424+
* @type {string}
3425+
* @memberof ExpelledCourseDto
3426+
*/
3427+
'name': string;
3428+
/**
3429+
*
3430+
* @type {string}
3431+
* @memberof ExpelledCourseDto
3432+
*/
3433+
'fullName': string;
3434+
/**
3435+
*
3436+
* @type {string}
3437+
* @memberof ExpelledCourseDto
3438+
*/
3439+
'alias': string;
3440+
/**
3441+
*
3442+
* @type {string}
3443+
* @memberof ExpelledCourseDto
3444+
*/
3445+
'description': string;
3446+
/**
3447+
*
3448+
* @type {string}
3449+
* @memberof ExpelledCourseDto
3450+
*/
3451+
'logo': string;
3452+
}
3453+
/**
3454+
*
3455+
* @export
3456+
* @interface ExpelledStatsDto
3457+
*/
3458+
export interface ExpelledStatsDto {
3459+
/**
3460+
*
3461+
* @type {string}
3462+
* @memberof ExpelledStatsDto
3463+
*/
3464+
'id': string;
3465+
/**
3466+
*
3467+
* @type {ExpelledCourseDto}
3468+
* @memberof ExpelledStatsDto
3469+
*/
3470+
'course': ExpelledCourseDto;
3471+
/**
3472+
*
3473+
* @type {ExpelledUserDto}
3474+
* @memberof ExpelledStatsDto
3475+
*/
3476+
'user': ExpelledUserDto;
3477+
/**
3478+
*
3479+
* @type {Array<string>}
3480+
* @memberof ExpelledStatsDto
3481+
*/
3482+
'reasonForLeaving'?: Array<string>;
3483+
/**
3484+
*
3485+
* @type {string}
3486+
* @memberof ExpelledStatsDto
3487+
*/
3488+
'otherComment': string;
3489+
/**
3490+
*
3491+
* @type {string}
3492+
* @memberof ExpelledStatsDto
3493+
*/
3494+
'submittedAt': string;
3495+
}
3496+
/**
3497+
*
3498+
* @export
3499+
* @interface ExpelledUserDto
3500+
*/
3501+
export interface ExpelledUserDto {
3502+
/**
3503+
*
3504+
* @type {number}
3505+
* @memberof ExpelledUserDto
3506+
*/
3507+
'id': number;
3508+
/**
3509+
*
3510+
* @type {string}
3511+
* @memberof ExpelledUserDto
3512+
*/
3513+
'githubId': string;
3514+
}
34103515
/**
34113516
*
34123517
* @export
@@ -10249,6 +10354,39 @@ export const CourseStatsApiAxiosParamCreator = function (configuration?: Configu
1024910354

1025010355

1025110356

10357+
setSearchParams(localVarUrlObj, localVarQueryParameter);
10358+
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
10359+
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
10360+
10361+
return {
10362+
url: toPathString(localVarUrlObj),
10363+
options: localVarRequestOptions,
10364+
};
10365+
},
10366+
/**
10367+
*
10368+
* @param {number} courseId
10369+
* @param {*} [options] Override http request option.
10370+
* @throws {RequiredError}
10371+
*/
10372+
getCourseExpelledStats: async (courseId: number, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
10373+
// verify required parameter 'courseId' is not null or undefined
10374+
assertParamExists('getCourseExpelledStats', 'courseId', courseId)
10375+
const localVarPath = `/courses/{courseId}/stats/expelled`
10376+
.replace(`{${"courseId"}}`, encodeURIComponent(String(courseId)));
10377+
// use dummy base URL string because the URL constructor only accepts absolute URLs.
10378+
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
10379+
let baseOptions;
10380+
if (configuration) {
10381+
baseOptions = configuration.baseOptions;
10382+
}
10383+
10384+
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
10385+
const localVarHeaderParameter = {} as any;
10386+
const localVarQueryParameter = {} as any;
10387+
10388+
10389+
1025210390
setSearchParams(localVarUrlObj, localVarQueryParameter);
1025310391
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
1025410392
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -10552,6 +10690,16 @@ export const CourseStatsApiFp = function(configuration?: Configuration) {
1055210690
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteExpelledStat(id, options);
1055310691
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
1055410692
},
10693+
/**
10694+
*
10695+
* @param {number} courseId
10696+
* @param {*} [options] Override http request option.
10697+
* @throws {RequiredError}
10698+
*/
10699+
async getCourseExpelledStats(courseId: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<ExpelledStatsDto>>> {
10700+
const localVarAxiosArgs = await localVarAxiosParamCreator.getCourseExpelledStats(courseId, options);
10701+
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
10702+
},
1055510703
/**
1055610704
*
1055710705
* @param {number} courseId
@@ -10618,7 +10766,7 @@ export const CourseStatsApiFp = function(configuration?: Configuration) {
1061810766
* @param {*} [options] Override http request option.
1061910767
* @throws {RequiredError}
1062010768
*/
10621-
async getExpelledStats(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<CourseStatsDto>>> {
10769+
async getExpelledStats(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<ExpelledStatsDto>>> {
1062210770
const localVarAxiosArgs = await localVarAxiosParamCreator.getExpelledStats(options);
1062310771
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
1062410772
},
@@ -10652,6 +10800,15 @@ export const CourseStatsApiFactory = function (configuration?: Configuration, ba
1065210800
deleteExpelledStat(id: string, options?: any): AxiosPromise<string> {
1065310801
return localVarFp.deleteExpelledStat(id, options).then((request) => request(axios, basePath));
1065410802
},
10803+
/**
10804+
*
10805+
* @param {number} courseId
10806+
* @param {*} [options] Override http request option.
10807+
* @throws {RequiredError}
10808+
*/
10809+
getCourseExpelledStats(courseId: number, options?: any): AxiosPromise<Array<ExpelledStatsDto>> {
10810+
return localVarFp.getCourseExpelledStats(courseId, options).then((request) => request(axios, basePath));
10811+
},
1065510812
/**
1065610813
*
1065710814
* @param {number} courseId
@@ -10712,7 +10869,7 @@ export const CourseStatsApiFactory = function (configuration?: Configuration, ba
1071210869
* @param {*} [options] Override http request option.
1071310870
* @throws {RequiredError}
1071410871
*/
10715-
getExpelledStats(options?: any): AxiosPromise<Array<CourseStatsDto>> {
10872+
getExpelledStats(options?: any): AxiosPromise<Array<ExpelledStatsDto>> {
1071610873
return localVarFp.getExpelledStats(options).then((request) => request(axios, basePath));
1071710874
},
1071810875
/**
@@ -10746,6 +10903,17 @@ export class CourseStatsApi extends BaseAPI {
1074610903
return CourseStatsApiFp(this.configuration).deleteExpelledStat(id, options).then((request) => request(this.axios, this.basePath));
1074710904
}
1074810905

10906+
/**
10907+
*
10908+
* @param {number} courseId
10909+
* @param {*} [options] Override http request option.
10910+
* @throws {RequiredError}
10911+
* @memberof CourseStatsApi
10912+
*/
10913+
public getCourseExpelledStats(courseId: number, options?: AxiosRequestConfig) {
10914+
return CourseStatsApiFp(this.configuration).getCourseExpelledStats(courseId, options).then((request) => request(this.axios, this.basePath));
10915+
}
10916+
1074910917
/**
1075010918
*
1075110919
* @param {number} courseId

client/src/modules/CourseManagement/components/ExpelledStudentsStats.tsx

Lines changed: 63 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Table, Typography, Tag, Button, Row } from 'antd';
22
import { ColumnsType, ColumnType } from 'antd/es/table';
3-
import React from 'react';
3+
import React, { useMemo } from 'react';
44
import { PublicSvgIcon } from '@client/components/Icons';
55
import { DEFAULT_COURSE_ICONS } from '@client/configs/course-icons';
66
import { dateUtcRenderer } from '@client/components/Table';
@@ -9,65 +9,70 @@ import { DetailedExpelledStat } from '@common/models';
99

1010
const { Title, Text } = Typography;
1111

12-
const ExpelledStudentsStats: React.FC = () => {
13-
const { data, error, loading, isDeleting, handleDelete } = useExpelledStats();
12+
type Props = {
13+
courseId?: number;
14+
};
15+
16+
const getColumns = (handleDelete: (id: string) => void, isDeleting: boolean): ColumnsType<DetailedExpelledStat> => [
17+
{
18+
title: 'Course',
19+
dataIndex: ['course', 'name'],
20+
key: 'courseName',
21+
render: (_text, record) => (
22+
<div>
23+
<PublicSvgIcon size="25px" src={DEFAULT_COURSE_ICONS[record.course.logo]?.active} />
24+
<Text strong style={{ marginLeft: 15 }}>
25+
{record.course.alias}
26+
</Text>
27+
<br />
28+
<Text type="secondary">{record.course.fullName || record.course.name}</Text>
29+
</div>
30+
),
31+
},
32+
{
33+
title: 'Student GitHub',
34+
dataIndex: ['user', 'githubId'],
35+
key: 'githubId',
36+
render: githubId => (
37+
<a href={`https://github.com/${githubId}`} target="_blank" rel="noopener noreferrer">
38+
{githubId}
39+
</a>
40+
),
41+
},
42+
{
43+
title: 'Reasons for Leaving',
44+
dataIndex: 'reasonForLeaving',
45+
key: 'reasons',
46+
render: (reasons?: string[]) => <>{reasons?.map(reason => <Tag key={reason}>{reason.replace(/_/g, ' ')}</Tag>)}</>,
47+
},
48+
{
49+
title: 'Other Comments',
50+
dataIndex: 'otherComment',
51+
key: 'otherComment',
52+
},
53+
{
54+
title: 'Date',
55+
dataIndex: 'submittedAt',
56+
key: 'date',
57+
render: dateUtcRenderer,
58+
},
59+
{
60+
title: 'Action',
61+
key: 'action',
62+
render: (_text, record) => (
63+
<Button danger onClick={() => handleDelete(record.id)} loading={isDeleting}>
64+
Delete
65+
</Button>
66+
),
67+
},
68+
];
69+
70+
const ExpelledStudentsStats: React.FC<Props> = ({ courseId }) => {
71+
const { data, error, loading, isDeleting, handleDelete } = useExpelledStats(courseId);
1472
const [csvUrl, setCsvUrl] = React.useState<string | null>(null);
1573
const downloadRef = React.useRef<HTMLAnchorElement>(null);
16-
const columns: ColumnsType<DetailedExpelledStat> = [
17-
{
18-
title: 'Course',
19-
dataIndex: ['course', 'name'],
20-
key: 'courseName',
21-
render: (_text, record) => (
22-
<div>
23-
<PublicSvgIcon size="25px" src={DEFAULT_COURSE_ICONS[record.course.logo]?.active} />
24-
<Text strong style={{ marginLeft: 15 }}>
25-
{record.course.alias}
26-
</Text>
27-
<br />
28-
<Text type="secondary">{record.course.fullName || record.course.name}</Text>
29-
</div>
30-
),
31-
},
32-
{
33-
title: 'Student GitHub',
34-
dataIndex: ['user', 'githubId'],
35-
key: 'githubId',
36-
render: githubId => (
37-
<a href={`https://github.com/${githubId}`} target="_blank" rel="noopener noreferrer">
38-
{githubId}
39-
</a>
40-
),
41-
},
42-
{
43-
title: 'Reasons for Leaving',
44-
dataIndex: 'reasonForLeaving',
45-
key: 'reasons',
46-
render: (reasons?: string[]) => (
47-
<>{reasons?.map(reason => <Tag key={reason}>{reason.replace(/_/g, ' ')}</Tag>)}</>
48-
),
49-
},
50-
{
51-
title: 'Other Comments',
52-
dataIndex: 'otherComment',
53-
key: 'otherComment',
54-
},
55-
{
56-
title: 'Date',
57-
dataIndex: 'submittedAt',
58-
key: 'date',
59-
render: dateUtcRenderer,
60-
},
61-
{
62-
title: 'Action',
63-
key: 'action',
64-
render: (_text, record) => (
65-
<Button danger onClick={() => handleDelete(record.id)} loading={isDeleting}>
66-
Delete
67-
</Button>
68-
),
69-
},
70-
];
74+
75+
const columns = useMemo(() => getColumns(handleDelete, isDeleting), [handleDelete, isDeleting]);
7176

7277
if (error) {
7378
return <Typography.Paragraph>Failed to load statistics.</Typography.Paragraph>;

client/src/modules/CourseManagement/hooks/useExpelledStats.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,26 @@
11
import { useRequest } from 'ahooks';
22
import { useState } from 'react';
3+
import { CourseStatsApi } from 'api';
34
import { DetailedExpelledStat } from '@common/models';
45

5-
const fetchExpelledStats = async (): Promise<DetailedExpelledStat[]> => {
6-
const response = await fetch('/api/v2/courses/stats/expelled');
7-
if (!response.ok) {
8-
throw new Error('Failed to fetch stats');
9-
}
10-
return response.json() as Promise<DetailedExpelledStat[]>;
6+
const courseStatsApi = new CourseStatsApi();
7+
8+
const fetchExpelledStats = async (courseId: number): Promise<DetailedExpelledStat[]> => {
9+
const response = await courseStatsApi.getCourseExpelledStats(courseId);
10+
return response.data;
1111
};
1212

13-
export const useExpelledStats = () => {
14-
const { data, error, loading, refresh } = useRequest(fetchExpelledStats);
13+
export const useExpelledStats = (courseId?: number) => {
14+
const { data, error, loading, refresh } = useRequest(() => fetchExpelledStats(courseId as number), {
15+
ready: !!courseId,
16+
refreshDeps: [courseId],
17+
});
1518
const [isDeleting, setIsDeleting] = useState(false);
1619

1720
const handleDelete = async (id: string) => {
1821
setIsDeleting(true);
1922
try {
20-
const response = await fetch(`/api/v2/courses/stats/expelled/${id}`, {
21-
method: 'DELETE',
22-
});
23-
if (!response.ok) {
24-
throw new Error('Failed to delete stat');
25-
}
23+
await courseStatsApi.deleteExpelledStat(id);
2624
if (typeof refresh === 'function') {
2725
refresh();
2826
}

0 commit comments

Comments
 (0)