Skip to content

Commit

Permalink
feat: create students countries stats widget (#2406)
Browse files Browse the repository at this point in the history
  • Loading branch information
valerydluski authored Jan 21, 2024
1 parent 47e906c commit 5ca3aaa
Show file tree
Hide file tree
Showing 18 changed files with 3,564 additions and 12 deletions.
95 changes: 95 additions & 0 deletions client/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,25 @@ export interface CountryDto {
*/
'countryName': string;
}
/**
*
* @export
* @interface CountryStatDto
*/
export interface CountryStatDto {
/**
*
* @type {string}
* @memberof CountryStatDto
*/
'country': string;
/**
*
* @type {number}
* @memberof CountryStatDto
*/
'studentsCount': number;
}
/**
*
* @export
Expand Down Expand Up @@ -5000,6 +5019,19 @@ export interface StudentId {
*/
'id': number;
}
/**
*
* @export
* @interface StudentsCountriesStatsDto
*/
export interface StudentsCountriesStatsDto {
/**
*
* @type {Array<CountryStatDto>}
* @memberof StudentsCountriesStatsDto
*/
'countries': Array<CountryStatDto>;
}
/**
*
* @export
Expand Down Expand Up @@ -7980,6 +8012,39 @@ export const CourseStatsApiAxiosParamCreator = function (configuration?: Configu



setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};

return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {number} courseId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getCourseStudentCountries: async (courseId: number, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'courseId' is not null or undefined
assertParamExists('getCourseStudentCountries', 'courseId', courseId)
const localVarPath = `/courses/{courseId}/stats/students/countries`
.replace(`{${"courseId"}}`, encodeURIComponent(String(courseId)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}

const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;



setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
Expand Down Expand Up @@ -8009,6 +8074,16 @@ export const CourseStatsApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getCourseStats(courseId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {number} courseId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getCourseStudentCountries(courseId: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<StudentsCountriesStatsDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getCourseStudentCountries(courseId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};

Expand All @@ -8028,6 +8103,15 @@ export const CourseStatsApiFactory = function (configuration?: Configuration, ba
getCourseStats(courseId: number, options?: any): AxiosPromise<CourseStatsDto> {
return localVarFp.getCourseStats(courseId, options).then((request) => request(axios, basePath));
},
/**
*
* @param {number} courseId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getCourseStudentCountries(courseId: number, options?: any): AxiosPromise<StudentsCountriesStatsDto> {
return localVarFp.getCourseStudentCountries(courseId, options).then((request) => request(axios, basePath));
},
};
};

Expand All @@ -8048,6 +8132,17 @@ export class CourseStatsApi extends BaseAPI {
public getCourseStats(courseId: number, options?: AxiosRequestConfig) {
return CourseStatsApiFp(this.configuration).getCourseStats(courseId, options).then((request) => request(this.axios, this.basePath));
}

/**
*
* @param {number} courseId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof CourseStatsApi
*/
public getCourseStudentCountries(courseId: number, options?: AxiosRequestConfig) {
return CourseStatsApiFp(this.configuration).getCourseStudentCountries(courseId, options).then((request) => request(this.axios, this.basePath));
}
}


Expand Down
6 changes: 6 additions & 0 deletions client/src/components/Sider/data/menuItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ export function getAdminMenuItems(session: Session): MenuItemsRenderData[] {
}

const courseManagementMenuItems: CourseManagementMenuItemsData[] = [
{
name: 'Dashboard',
key: 'courseDashboard',
getUrl: (course: Course) => `/course/admin/dashboard?course=${course.alias}`,
courseAccess: some(isCourseManager, isCourseSupervisor, isDementor),
},
{
name: 'Course Events',
key: 'courseEvents',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Card } from 'antd';
import { StudentsCountriesStatsDto } from 'api';
import dynamic from 'next/dynamic';

type Props = {
studentsCountriesStats: StudentsCountriesStatsDto;
studentsActiveCount: number;
};

const StudentsCountriesChart = dynamic(() => import('./StudentsCountriesChart'), { ssr: false });

export const StudentsCountriesCard = ({ studentsCountriesStats, studentsActiveCount }: Props) => {
const { countries } = studentsCountriesStats;
return (
<Card title="Students Countries Stats">
<div style={{ height: 400, width: '100%' }}>
<StudentsCountriesChart data={countries} studentsActiveCount={studentsActiveCount} />
</div>
</Card>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Bar, BarConfig } from '@ant-design/plots';
import { Flex, Image, Typography } from 'antd';
import { CountryStatDto } from 'api';
import { useCallback, useMemo } from 'react';

type Props = {
data: CountryStatDto[];
studentsActiveCount: number;
};

/*
* Defining the Datum type manually as it cannot be imported directly from @ant-design/plots.
* This type is inferred from the 'data' property of the Bar component's first parameter.
* This approach is necessary because @ant-design/plots does not explicitly export the Datum type.
* Use this type definition cautiously and review it if the library updates.
*/
type Datum = Parameters<typeof Bar>[0]['data'][number];

const { Text } = Typography;

function StudentsCountriesChart({ data, studentsActiveCount }: Props) {
const tooltipFormatter = useCallback(
(datum: Datum) => {
const percentage = studentsActiveCount ? Math.ceil((datum.studentsCount / studentsActiveCount) * 100) : 0;
return {
name: 'Number of Students',
value: `${datum.studentsCount} (${percentage}%)`,
};
},
[studentsActiveCount],
);

const config: BarConfig = useMemo(
() => ({
data,
yField: 'country',
xField: 'studentsCount',
yAxis: {
label: { autoRotate: false },
},
tooltip: { formatter: tooltipFormatter },
xAxis: { title: { text: 'Number of Students' } },
scrollbar: { type: 'vertical' },
}),
[data, tooltipFormatter],
);

if (!data.length) {
return (
<Flex vertical gap="middle" align="center" justify="center">
<Text strong>No student data available to display</Text>
<Image preview={false} src="/static/svg/err.svg" alt="Error 404" width={175} height={175} />
</Flex>
);
}

return <Bar {...config} />;
}

export default StudentsCountriesChart;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { StudentsCountriesCard } from './StudentsCountriesCard';
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Card } from 'antd';
import { CourseStatsDto } from 'api';
import dynamic from 'next/dynamic';

type Props = {
studentsStats: CourseStatsDto;
};

const StudentsStatsChart = dynamic(() => import('./StudentsStatsChart'), { ssr: false });

export const StudentsStatsCard = ({ studentsStats }: Props) => {
return (
<Card title="Active Students Stats">
<div style={{ height: 200, width: '100%' }}>
<StudentsStatsChart studentsStats={studentsStats} />
</div>
</Card>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Liquid } from '@ant-design/plots';
import { CourseStatsDto } from 'api';

type Props = {
studentsStats: CourseStatsDto;
};

function StudentsStatsChart({ studentsStats }: Props) {
const percent = studentsStats.studentsActiveCount / studentsStats.studentsTotalCount;
const config = {
percent: percent,
outline: {
border: 4,
distance: 8,
},
wave: {
length: 128,
},
};
return <Liquid {...config} />;
}

export default StudentsStatsChart;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { StudentsStatsCard } from './StudentsStatsCard';
1 change: 1 addition & 0 deletions client/src/modules/AdminDashboard/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useCourseStats } from './useCourseStats/useCourseStats';
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { message } from 'antd';
import { CourseStatsApi } from 'api';
import { useAsync } from 'react-use';

const courseStatsApi = new CourseStatsApi();

export function useCourseStats(courseId: number) {
return useAsync(async () => {
try {
const [studentsCountries, studentsStats] = await Promise.all([
courseStatsApi.getCourseStudentCountries(courseId),
courseStatsApi.getCourseStats(courseId),
]);
return {
studentsCountries: studentsCountries.data,
studentsStats: studentsStats.data,
};
} catch (error) {
message.error('Something went wrong, please try to reload the page later');
}
}, [courseId]);
}
1 change: 1 addition & 0 deletions client/src/modules/AdminDashboard/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as AdminDashboard } from './pages/AdminDashboard';
72 changes: 72 additions & 0 deletions client/src/modules/AdminDashboard/pages/AdminDashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { PageLayout } from 'components/PageLayout';
import { useActiveCourseContext } from 'modules/Course/contexts';
import Masonry from 'react-masonry-css';
import { StudentsCountriesCard } from '../components/StudentsCountriesCard';
import { useCourseStats } from '../hooks';
import { StudentsStatsCard } from '../components/StudentsStatsCard';
import css from 'styled-jsx/css';

const gapSize = 24;

function AdminDashboard() {
const { course } = useActiveCourseContext();
const { loading, value: stats } = useCourseStats(course.id);

const masonryBreakPoints = {
default: 4,
1100: 3,
700: 2,
500: 1,
};

const cards = [
stats?.studentsCountries && {
title: 'studentsCountriesCard',
component: (
<StudentsCountriesCard
studentsCountriesStats={stats.studentsCountries}
studentsActiveCount={stats.studentsStats.studentsActiveCount}
/>
),
},
stats?.studentsStats && {
title: 'studentsStatsCard',
component: <StudentsStatsCard studentsStats={stats.studentsStats} />,
},
].filter(Boolean);

return (
<PageLayout loading={loading} title="Dashboard" background="#F0F2F5" showCourseName>
<Masonry
breakpointCols={masonryBreakPoints}
className={masonryClassName}
columnClassName={masonryColumnClassName}
>
{cards.map(({ title, component }) => (
<div style={{ marginBottom: gapSize }} key={title}>
{component}
</div>
))}
</Masonry>
{masonryStyles}
{masonryColumnStyles}
</PageLayout>
);
}

const { className: masonryClassName, styles: masonryStyles } = css.resolve`
div {
display: flex;
margin-left: -${gapSize}px;
width: auto;
min-height: 85vh;
}
`;
const { className: masonryColumnClassName, styles: masonryColumnStyles } = css.resolve`
div {
padding-left: ${gapSize}px;
background-clip: padding-box;
}
`;

export default AdminDashboard;
15 changes: 15 additions & 0 deletions client/src/pages/course/admin/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { AdminDashboard } from 'modules/AdminDashboard';
import { ActiveCourseProvider, SessionProvider } from 'modules/Course/contexts';
import { CourseRole } from 'services/models';

function Page() {
return (
<SessionProvider allowedRoles={[CourseRole.Manager, CourseRole.Supervisor, CourseRole.Dementor]}>
<ActiveCourseProvider>
<AdminDashboard />
</ActiveCourseProvider>
</SessionProvider>
);
}

export default Page;
Loading

0 comments on commit 5ca3aaa

Please sign in to comment.